@tellescope/sdk 1.255.18 → 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/sdk.d.ts +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- 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/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- 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/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/cjs/tests/api_tests/scoped_api_keys.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-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +489 -255
- 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 +9 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.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/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.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/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- 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/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/esm/tests/api_tests/scoped_api_keys.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-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +489 -255
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- 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/healthie_multi_integration.test.ts +103 -5
- 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/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +5 -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-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +232 -2
- package/test_generated.pdf +0 -0
- package/.env +0 -13
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { Session, EnduserSession } from "../../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
assert,
|
|
7
|
+
log_header,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests, authenticate_enduser_via_token } from "../../setup"
|
|
10
|
+
import { Enduser } from "@tellescope/types-client"
|
|
11
|
+
|
|
12
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Regression test for: email / phone are locked against writes from public session-start endpoints.
|
|
16
|
+
*
|
|
17
|
+
* Two public, unauthenticated endpoints match an existing patient on a loose $or
|
|
18
|
+
* ({ email } OR { fname, lname, dateOfBirth }) and then update the matched record:
|
|
19
|
+
*
|
|
20
|
+
* A POST /v1/session-for-public-form
|
|
21
|
+
* B POST /v1/session-for-public-appointment-booking
|
|
22
|
+
*
|
|
23
|
+
* Both used to overwrite `email`, `phone` and `state` on the matched patient. `email` and `phone`
|
|
24
|
+
* are authentication channels, so that overwrite chains to account takeover:
|
|
25
|
+
*
|
|
26
|
+
* - email-less patients are common and cheap to create (sms_messages.leave_message is an
|
|
27
|
+
* unauthenticated enduser-creation primitive that writes only phone/fname/lname)
|
|
28
|
+
* - attacker submits a public form matching on fname + lname + dateOfBirth with their own email
|
|
29
|
+
* - POST /v1/request-enduser-password-reset resolves purely by findOne({ email }) -> mints a
|
|
30
|
+
* passwordReset token and mails the reset link to the attacker
|
|
31
|
+
* - reset_password sets hashedPassword; the attacker logs into the portal as the patient
|
|
32
|
+
*
|
|
33
|
+
* phone is equally load-bearing: begin_login_flow accepts email OR phone, and send_otp delivers
|
|
34
|
+
* login codes to enduser.phone. A "fill only if blank" fix does NOT close this — the email-less
|
|
35
|
+
* patient is precisely the common case, which is why the UNSET shapes below are the load-bearing
|
|
36
|
+
* rows of G1/G2.
|
|
37
|
+
*
|
|
38
|
+
* The rule under test: `email` and `phone` may be written only on the INSERT path. On a matched
|
|
39
|
+
* existing patient they are never written — not even when the stored value is blank.
|
|
40
|
+
*
|
|
41
|
+
* `state` deliberately still overwrites on match (G6 pins the status quo): it is not an auth
|
|
42
|
+
* channel, and moving it behind "if unset" would break a relocated patient booking with a
|
|
43
|
+
* provider licensed in their new state.
|
|
44
|
+
*
|
|
45
|
+
* Scenario groups (pre-fix expectation in parens):
|
|
46
|
+
* G1 (RED) A — overwrite blocked on match, 4 victim shapes (both set / email unset / phone unset / both unset)
|
|
47
|
+
* G2 (RED) B — same 4 shapes
|
|
48
|
+
* G3 (RED) A — overwrite blocked when the victim is targeted directly via the enduserId param
|
|
49
|
+
* G4 (RED) takeover chain: email-less victim -> overwrite via A -> password-reset resolvability
|
|
50
|
+
* G5 (GREEN) blank fname/lname/dateOfBirth/gender still filled; blank phone still NOT filled
|
|
51
|
+
* G6 (GREEN) state still overwrites on match (status-quo pin)
|
|
52
|
+
* G7 (GREEN) brand-new visitor still created WITH submitted email + phone
|
|
53
|
+
* G8 (GREEN) form submission completes end-to-end under the returned token
|
|
54
|
+
* G9 (GREEN) appointment availability / booking works under the returned session
|
|
55
|
+
* G10 (GREEN) skipMatch still inserts, and skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge
|
|
56
|
+
*
|
|
57
|
+
* SECOND WRITE PATH (G11-G18). Locking the endpoints is not sufficient on its own. `email` and
|
|
58
|
+
* `phone` are also UI-selectable form intakeField mappings, and save_enduser_updates_for_form_response
|
|
59
|
+
* (api/modules/forms.ts) copies those answers onto the enduser named by the form response — with no
|
|
60
|
+
* session argument and no only-if-blank guard. Two of its callers sit in the 'public-form' session scope
|
|
61
|
+
* (submit_form_response, and save_field_response for fields with options.saveIntakeOnPartial), so the
|
|
62
|
+
* whole chain above is reachable with the token the hardened endpoint itself mints: match the victim,
|
|
63
|
+
* get a session scoped to their id, then answer the form's Email question with the attacker address.
|
|
64
|
+
*
|
|
65
|
+
* The lock is scoped to "a public session that matched a PRE-EXISTING patient", not to every public
|
|
66
|
+
* session, because intakeEmailHidden / intakePhone: 'hidden' are supported configurations in which
|
|
67
|
+
* the intake question is the only source of a brand-new patient's contact info (G16).
|
|
68
|
+
*
|
|
69
|
+
* G11 (RED) intake answers do not overwrite a matched patient (submit path), 4 victim shapes
|
|
70
|
+
* G12 (RED) same via save_field_response + saveIntakeOnPartial, without ever submitting
|
|
71
|
+
* G13 (RED) full takeover chain driven through an intake answer
|
|
72
|
+
* G14 (RED) pivot: public session -> prepare_form_response for a NON-public form -> submit
|
|
73
|
+
* G15 (RED) save_field_response rejects another patient's formResponseId (accessCode path stays open)
|
|
74
|
+
* G16 (GREEN) brand-new visitor still gets email/phone from intake answers
|
|
75
|
+
* G17 (GREEN) authenticated (non-public) portal session can still self-update via intake
|
|
76
|
+
* G18 (GREEN) staff submitting on a patient's behalf can still set email/phone
|
|
77
|
+
*
|
|
78
|
+
* NULL-MATCH / COLD START (G19-G22). In both handlers' $or, `email` and `dateOfBirth` get `|| "never"`
|
|
79
|
+
* sentinels but fname/lname are interpolated RAW. `ignoreUndefined` is not set on the Mongo client and
|
|
80
|
+
* findOne does not strip undefined keys, so an absent fname serializes to `{fname: null}` — which
|
|
81
|
+
* matches every document where the field is null OR MISSING. A request supplying ONLY dateOfBirth can
|
|
82
|
+
* therefore match another patient and receive a session scoped to THEIR record. The cold-start pattern
|
|
83
|
+
* (a public form started with no intake info, which just creates an Enduser with an ID) is what
|
|
84
|
+
* manufactures those nameless endusers.
|
|
85
|
+
*
|
|
86
|
+
* G19 (RED) DOB-only request does not hijack a nameless patient — form endpoint
|
|
87
|
+
* G20 (RED) same — booking endpoint (no skipMatch, no phone guard, so reached unconditionally)
|
|
88
|
+
* G21 (GREEN) the match sentinel never leaks into a stored fname/lname
|
|
89
|
+
* G22 (GREEN) cold start still lets intake answers populate email/phone on the fresh record
|
|
90
|
+
*
|
|
91
|
+
* TENANT SCOPING (G23). addPublicEndpoint overwrites the schema's `required: true` for businessId with
|
|
92
|
+
* mongoIdStringOptional, and a falsy businessId leaves restrictAccess as the IDENTITY function in the
|
|
93
|
+
* query builders — the tenant clause is never appended, so the handler queries every tenant. It fails
|
|
94
|
+
* OPEN. session_for_public_appointment_booking already guards this; session_for_public_form did not.
|
|
95
|
+
*
|
|
96
|
+
* G23 (RED) session_for_public_form rejects a missing or malformed businessId
|
|
97
|
+
*
|
|
98
|
+
* LOCK BYPASSES (G24-G26). The lock is defeated two ways, each needing more than one hop — which is
|
|
99
|
+
* why the single-hop groups above pass while the chain is still open:
|
|
100
|
+
* - prepare_form_response mints a token that preserves allowedPaths but DROPS fromPublicSession, so
|
|
101
|
+
* a second hop through the same endpoint produces an unlocked response. That flag also gates PHI
|
|
102
|
+
* on refresh_session, so the chained token additionally reads the victim's whole record.
|
|
103
|
+
* - the lock is a snapshot taken at session start; a later repoint of the response at a pre-existing
|
|
104
|
+
* patient leaves a stale `false` behind unless it also writes the flag. Two callers repoint:
|
|
105
|
+
* autoMergeOnSubmission (G26) and the staff endusers.merge handler (G27).
|
|
106
|
+
*
|
|
107
|
+
* G24 (RED) chained prepare_form_response session stays locked
|
|
108
|
+
* G25 (RED) chained session does not expose the enduser record on refresh
|
|
109
|
+
* G26 (RED) auto-merge does not let an intake answer repoint the merged patient
|
|
110
|
+
* G27 (RED) the staff endusers.merge repoint applies the lock too
|
|
111
|
+
*
|
|
112
|
+
* SCOPE BOUNDARY (G28). The formsort / form-ingestion webhook re-opens a blank email/phone for one
|
|
113
|
+
* hour after the Stripe integration creates a contact, because that endpoint is API-key
|
|
114
|
+
* authenticated. This anonymous path gets no such exemption.
|
|
115
|
+
*
|
|
116
|
+
* G28 a fresh Stripe-created patient gets no grace here
|
|
117
|
+
*
|
|
118
|
+
* STRUCTURE: record -> report -> cleanup -> assert.
|
|
119
|
+
* EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
|
|
120
|
+
* first failure. Two consequences shape this file:
|
|
121
|
+
* 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
|
|
122
|
+
* single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
|
|
123
|
+
* instead of dying on the first probe.
|
|
124
|
+
* 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
|
|
125
|
+
* loop; otherwise a by-design-failing run would leak every fixture into the next run.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
const post = async (path: string, body: any) => {
|
|
129
|
+
try {
|
|
130
|
+
const res = await axios.post(`${host}${path}`, body, { validateStatus: () => true })
|
|
131
|
+
return { status: res.status, data: res.data as any }
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
return { status: err?.response?.status as number, data: err?.response?.data as any }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type ScenarioResult = { group: string, name: string, ok: boolean, skipped?: boolean, detail: string }
|
|
138
|
+
|
|
139
|
+
// Endpoint A's update is fire-and-forget (.catch(console.error), not awaited); B's is awaited.
|
|
140
|
+
// Reading the victim immediately after calling A would frequently show the un-overwritten value on
|
|
141
|
+
// a VULNERABLE server -> false pass. So poll for the attack landing: fails fast against vulnerable
|
|
142
|
+
// code, burns the full window against fixed code.
|
|
143
|
+
const OVERWRITE_WINDOW_MS = 3000
|
|
144
|
+
const AWAITED_WINDOW_MS = 1200
|
|
145
|
+
const POLL_INTERVAL_MS = 150
|
|
146
|
+
|
|
147
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
148
|
+
|
|
149
|
+
export const public_endpoint_auth_field_lock_tests = async (
|
|
150
|
+
{ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
|
|
151
|
+
) => {
|
|
152
|
+
log_header("Public Endpoint Auth Field Lock (email / phone)")
|
|
153
|
+
|
|
154
|
+
const businessId = sdk.userInfo.businessId
|
|
155
|
+
// Every identity carries a per-run token so the handlers' $or can only match our own records,
|
|
156
|
+
// and re-runs never collide.
|
|
157
|
+
const RUN = Date.now().toString(36)
|
|
158
|
+
|
|
159
|
+
const results: ScenarioResult[] = []
|
|
160
|
+
const record = (group: string, name: string, ok: boolean, detail = '') => {
|
|
161
|
+
results.push({ group, name, ok, detail })
|
|
162
|
+
console.log(` ${ok ? '✓' : '✗'} [${group}] ${name}${detail ? ` — ${detail}` : ''}`)
|
|
163
|
+
}
|
|
164
|
+
const skip = (group: string, name: string, detail: string) => {
|
|
165
|
+
results.push({ group, name, ok: true, skipped: true, detail })
|
|
166
|
+
console.log(` ⚠ SKIP [${group}] ${name} — ${detail}`)
|
|
167
|
+
}
|
|
168
|
+
// Converts a thrown exception into a recorded failure so one surprise doesn't abort the table.
|
|
169
|
+
const runScenario = async (group: string, name: string, fn: () => Promise<void>) => {
|
|
170
|
+
try {
|
|
171
|
+
await fn()
|
|
172
|
+
} catch (err: any) {
|
|
173
|
+
const message = err?.message ?? err?.error ?? JSON.stringify(err ?? {}).slice(0, 300)
|
|
174
|
+
record(group, name, false, `threw: ${message}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// phoneValidator normalizes to E.164, so generate +1555555xxxx values that are byte-comparable
|
|
179
|
+
// against what gets stored. 555-555-xxxx is the only 555 exchange libphonenumber accepts.
|
|
180
|
+
let phoneOffset = 0
|
|
181
|
+
const phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0')
|
|
182
|
+
const nextPhone = () => `+1555555${String((phoneSeed + (phoneOffset += 1)) % 10_000).padStart(4, '0')}`
|
|
183
|
+
|
|
184
|
+
// The API lowercases stored emails, so normalize here too — the overwrite predicates compare
|
|
185
|
+
// submitted and stored values directly.
|
|
186
|
+
const emailFor = (label: string) => `${label}-${RUN}@tellescope.com`.toLowerCase()
|
|
187
|
+
|
|
188
|
+
const createdEnduserIds: string[] = []
|
|
189
|
+
const createdFormIds: string[] = []
|
|
190
|
+
const createdFormFieldIds: string[] = []
|
|
191
|
+
const createdUserIds: string[] = []
|
|
192
|
+
const createdEventIds: string[] = []
|
|
193
|
+
const createdTemplateIds: string[] = []
|
|
194
|
+
|
|
195
|
+
// matchEmailAndNames makes the email branch of the $or additionally require fname + lname, which
|
|
196
|
+
// changes which victim shapes are reachable in G5 (a fully blank victim can't be matched at all).
|
|
197
|
+
const organization: any = await sdk.api.organizations.getOne(businessId).catch(() => null)
|
|
198
|
+
const MATCH_EMAIL_AND_NAMES = !!organization?.settings?.endusers?.matchEmailAndNames
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------------------------
|
|
201
|
+
// Fixtures
|
|
202
|
+
// ---------------------------------------------------------------------------------------------
|
|
203
|
+
// The public form has exactly one question with NO intakeField, so submitting it does not
|
|
204
|
+
// legitimately write email/phone and therefore cannot confound G8.
|
|
205
|
+
const form = await sdk.api.forms.createOne({ title: `auth field lock ${RUN}`, allowPublicURL: true })
|
|
206
|
+
createdFormIds.push(form.id)
|
|
207
|
+
const plainField = await sdk.api.form_fields.createOne({
|
|
208
|
+
formId: form.id,
|
|
209
|
+
title: 'Anything',
|
|
210
|
+
type: 'string',
|
|
211
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
212
|
+
})
|
|
213
|
+
createdFormFieldIds.push(plainField.id)
|
|
214
|
+
|
|
215
|
+
const autoMergeForm = await sdk.api.forms.createOne({
|
|
216
|
+
title: `auth field lock automerge ${RUN}`,
|
|
217
|
+
allowPublicURL: true,
|
|
218
|
+
autoMergeOnSubmission: true,
|
|
219
|
+
})
|
|
220
|
+
createdFormIds.push(autoMergeForm.id)
|
|
221
|
+
const autoMergeField = await sdk.api.form_fields.createOne({
|
|
222
|
+
formId: autoMergeForm.id,
|
|
223
|
+
title: 'Anything',
|
|
224
|
+
type: 'string',
|
|
225
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
226
|
+
})
|
|
227
|
+
createdFormFieldIds.push(autoMergeField.id)
|
|
228
|
+
|
|
229
|
+
// Intake-mapped forms, for G11-G18.
|
|
230
|
+
//
|
|
231
|
+
// `email` and `phone` are first-class, UI-selectable intakeField mappings (both are keys of
|
|
232
|
+
// ENDUSER_FIELD_TYPES), and save_enduser_updates_for_form_response (api/modules/forms.ts) copies
|
|
233
|
+
// those answers onto the enduser named by the form response. That is a SECOND write path, reached
|
|
234
|
+
// with the very token session_for_public_form mints — so the endpoint-level lock covered by
|
|
235
|
+
// G1-G4 is worthless unless this path is guarded too.
|
|
236
|
+
//
|
|
237
|
+
// intakePhone: 'hidden' removes the endpoint's "Phone number is required" check, which lets these
|
|
238
|
+
// scenarios call the endpoint with NO email/phone at all. That isolates the intake write path
|
|
239
|
+
// completely: any change to the victim's email/phone can only have come from an intake answer.
|
|
240
|
+
// It also mirrors the real intakeEmailHidden/intakePhone:'hidden' configuration in which the
|
|
241
|
+
// intake question is the ONLY source of a new patient's contact info (see G16).
|
|
242
|
+
const makeIntakeForm = async (label: string, opts: { public?: boolean, saveIntakeOnPartial?: boolean } = {}) => {
|
|
243
|
+
const intakeForm = await sdk.api.forms.createOne({
|
|
244
|
+
title: `auth field lock ${label} ${RUN}`,
|
|
245
|
+
intakePhone: 'hidden',
|
|
246
|
+
...(opts.public === false ? {} : { allowPublicURL: true }),
|
|
247
|
+
} as any)
|
|
248
|
+
createdFormIds.push(intakeForm.id)
|
|
249
|
+
|
|
250
|
+
const fieldOptions = opts.saveIntakeOnPartial ? { options: { saveIntakeOnPartial: true } } : {}
|
|
251
|
+
|
|
252
|
+
const emailField = await sdk.api.form_fields.createOne({
|
|
253
|
+
formId: intakeForm.id,
|
|
254
|
+
title: 'Email',
|
|
255
|
+
type: 'email',
|
|
256
|
+
intakeField: 'email',
|
|
257
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
258
|
+
...fieldOptions,
|
|
259
|
+
} as any)
|
|
260
|
+
createdFormFieldIds.push(emailField.id)
|
|
261
|
+
|
|
262
|
+
const phoneField = await sdk.api.form_fields.createOne({
|
|
263
|
+
formId: intakeForm.id,
|
|
264
|
+
title: 'Phone Number',
|
|
265
|
+
type: 'phone',
|
|
266
|
+
intakeField: 'phone',
|
|
267
|
+
previousFields: [{ type: 'after', info: { fieldId: emailField.id } }],
|
|
268
|
+
...fieldOptions,
|
|
269
|
+
} as any)
|
|
270
|
+
createdFormFieldIds.push(phoneField.id)
|
|
271
|
+
|
|
272
|
+
return { form: intakeForm, emailField, phoneField }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const intake = await makeIntakeForm('intake')
|
|
276
|
+
const partialIntake = await makeIntakeForm('partial', { saveIntakeOnPartial: true })
|
|
277
|
+
// Deliberately NOT allowPublicURL. prepare_form_response does not require it, so a public session
|
|
278
|
+
// can pivot from any public form to any form in the org carrying an email/phone intake mapping.
|
|
279
|
+
const pivotIntake = await makeIntakeForm('pivot', { public: false })
|
|
280
|
+
|
|
281
|
+
// Auto-merge fixture for G26. Needs real intakeField mappings, because resolveAutoMergeBeforeSubmission
|
|
282
|
+
// derives its match values from the submitted answers via those mappings — and the custom field is the
|
|
283
|
+
// anti-vacuity control (it proves the intake write ran at all).
|
|
284
|
+
const MERGE_CUSTOM_KEY = `aflMerge${RUN}`
|
|
285
|
+
const autoMergeIntake = await (async () => {
|
|
286
|
+
const form = await sdk.api.forms.createOne({
|
|
287
|
+
title: `auth field lock automerge intake ${RUN}`,
|
|
288
|
+
allowPublicURL: true,
|
|
289
|
+
autoMergeOnSubmission: true,
|
|
290
|
+
intakePhone: 'hidden',
|
|
291
|
+
} as any)
|
|
292
|
+
createdFormIds.push(form.id)
|
|
293
|
+
|
|
294
|
+
const spec = [
|
|
295
|
+
{ title: 'First Name', type: 'string', intakeField: 'fname' },
|
|
296
|
+
{ title: 'Last Name', type: 'string', intakeField: 'lname' },
|
|
297
|
+
{ title: 'Date of Birth', type: 'dateString', intakeField: 'dateOfBirth' },
|
|
298
|
+
{ title: 'Email', type: 'email', intakeField: 'email' },
|
|
299
|
+
{ title: 'Custom', type: 'string', intakeField: MERGE_CUSTOM_KEY },
|
|
300
|
+
]
|
|
301
|
+
const made: any[] = []
|
|
302
|
+
for (const s of spec) {
|
|
303
|
+
const field = await sdk.api.form_fields.createOne({
|
|
304
|
+
formId: form.id,
|
|
305
|
+
title: s.title,
|
|
306
|
+
type: s.type,
|
|
307
|
+
intakeField: s.intakeField,
|
|
308
|
+
previousFields: made.length
|
|
309
|
+
? [{ type: 'after', info: { fieldId: made[made.length - 1].id } }]
|
|
310
|
+
: [{ type: 'root', info: {} }],
|
|
311
|
+
} as any)
|
|
312
|
+
createdFormFieldIds.push(field.id)
|
|
313
|
+
made.push(field)
|
|
314
|
+
}
|
|
315
|
+
const [fname, lname, dateOfBirth, email, custom] = made
|
|
316
|
+
return { form, fname, lname, dateOfBirth, email, custom }
|
|
317
|
+
})()
|
|
318
|
+
|
|
319
|
+
const template = await sdk.api.calendar_event_templates.createOne({
|
|
320
|
+
title: `auth field lock ${RUN}`,
|
|
321
|
+
durationInMinutes: 60,
|
|
322
|
+
confirmationEmailDisabled: true,
|
|
323
|
+
confirmationSMSDisabled: true,
|
|
324
|
+
reminders: [],
|
|
325
|
+
})
|
|
326
|
+
createdTemplateIds.push(template.id)
|
|
327
|
+
|
|
328
|
+
// Throwaway host user with all-7-day availability, so G9 has slots to find without depending on
|
|
329
|
+
// (or mutating) the shared test user's calendar configuration.
|
|
330
|
+
const hostUser = await sdk.api.users.createOne({
|
|
331
|
+
email: emailFor('lock-host'),
|
|
332
|
+
verifiedEmail: true,
|
|
333
|
+
fname: 'Lock',
|
|
334
|
+
lname: `Host${RUN}`,
|
|
335
|
+
})
|
|
336
|
+
createdUserIds.push(hostUser.id)
|
|
337
|
+
await sdk.api.users.updateOne(hostUser.id, {
|
|
338
|
+
weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(dayOfWeekStartingSundayIndexedByZero => ({
|
|
339
|
+
dayOfWeekStartingSundayIndexedByZero,
|
|
340
|
+
startTimeInMinutes: 9 * 60,
|
|
341
|
+
endTimeInMinutes: 17 * 60,
|
|
342
|
+
intervalInMinutes: 60,
|
|
343
|
+
})),
|
|
344
|
+
calendarEventLimits: [],
|
|
345
|
+
}, { replaceObjectFields: true })
|
|
346
|
+
|
|
347
|
+
await sleep(250) // let form.numFields settle before the public endpoints validate it
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------------------------
|
|
350
|
+
// Helpers
|
|
351
|
+
// ---------------------------------------------------------------------------------------------
|
|
352
|
+
const publicSDK = () => new EnduserSession({ host, businessId })
|
|
353
|
+
|
|
354
|
+
const startForm = (args: any) => publicSDK().api.form_responses.session_for_public_form({
|
|
355
|
+
formId: form.id, businessId, ...args,
|
|
356
|
+
})
|
|
357
|
+
const startBooking = (args: any) => publicSDK().api.calendar_events.session_for_public_appointment_booking({
|
|
358
|
+
calendarEventTemplateId: template.id, businessId, ...args,
|
|
359
|
+
})
|
|
360
|
+
const startFormOn = (formId: string, args: any) => publicSDK().api.form_responses.session_for_public_form({
|
|
361
|
+
formId, businessId, ...args,
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const asEnduser = (authToken: string) => new EnduserSession({ host, businessId, authToken })
|
|
365
|
+
|
|
366
|
+
// Raw POST with a Bearer token, so G25 can inspect exactly which fields the response body carries.
|
|
367
|
+
const postAuthed = async (path: string, body: any, authToken: string) => {
|
|
368
|
+
try {
|
|
369
|
+
const res = await axios.post(`${host}${path}`, body, {
|
|
370
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
371
|
+
validateStatus: () => true,
|
|
372
|
+
})
|
|
373
|
+
return { status: res.status, data: res.data as any }
|
|
374
|
+
} catch (err: any) {
|
|
375
|
+
return { status: err?.response?.status as number, data: err?.response?.data as any }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// prepare_form_response returns the token it mints inside fullURL (?token=...). Pulling it out is
|
|
380
|
+
// the whole point of G24/G25 — that token is what a caller actually receives.
|
|
381
|
+
const tokenFromURL = (fullURL?: string) => /[?&]token=([^&]+)/.exec(fullURL ?? '')?.[1]
|
|
382
|
+
|
|
383
|
+
// Raw PATCH so G15 can assert on the HTTP status; the SDK's parseError discards it.
|
|
384
|
+
const patchAuthed = async (path: string, body: any, authToken: string) => {
|
|
385
|
+
try {
|
|
386
|
+
const res = await axios.patch(`${host}${path}`, body, {
|
|
387
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
388
|
+
validateStatus: () => true,
|
|
389
|
+
})
|
|
390
|
+
return { status: res.status, data: res.data as any }
|
|
391
|
+
} catch (err: any) {
|
|
392
|
+
return { status: err?.response?.status as number, data: err?.response?.data as any }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
type IntakeFields = { emailField: { id: string, title: string }, phoneField: { id: string, title: string } }
|
|
397
|
+
const intakeAnswers = (f: IntakeFields, email: string, phone: string) => ([
|
|
398
|
+
{ fieldId: f.emailField.id, fieldTitle: f.emailField.title, answer: { type: 'email' as const, value: email } },
|
|
399
|
+
{ fieldId: f.phoneField.id, fieldTitle: f.phoneField.title, answer: { type: 'phone' as const, value: phone } },
|
|
400
|
+
])
|
|
401
|
+
|
|
402
|
+
const pollEnduser = async (id: string, landed: (e: Enduser) => boolean, windowMs: number) => {
|
|
403
|
+
const deadline = Date.now() + windowMs
|
|
404
|
+
let latest: Enduser | null = null
|
|
405
|
+
while (true) {
|
|
406
|
+
latest = await sdk.api.endusers.getOne(id).catch(() => null)
|
|
407
|
+
if (latest && landed(latest)) return { landed: true, enduser: latest }
|
|
408
|
+
if (Date.now() >= deadline) return { landed: false, enduser: latest }
|
|
409
|
+
await sleep(POLL_INTERVAL_MS)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const describe = (e: Enduser | null) => `email=${e?.email || '(unset)'} phone=${e?.phone || '(unset)'}`
|
|
414
|
+
|
|
415
|
+
const makeVictim = async (tag: string, fields: Partial<Enduser> = {}) => {
|
|
416
|
+
const victim = await sdk.api.endusers.createOne({
|
|
417
|
+
fname: `Lock${RUN}`,
|
|
418
|
+
lname: `Victim${RUN}${tag}`,
|
|
419
|
+
dateOfBirth: '01-02-1990',
|
|
420
|
+
...fields,
|
|
421
|
+
} as any)
|
|
422
|
+
createdEnduserIds.push(victim.id)
|
|
423
|
+
return victim
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const VICTIM_SHAPES = [
|
|
427
|
+
{ tag: 'A1', label: 'victim has email + phone', withEmail: true, withPhone: true },
|
|
428
|
+
{ tag: 'A2', label: 'victim email UNSET (load-bearing)', withEmail: false, withPhone: true },
|
|
429
|
+
{ tag: 'A3', label: 'victim phone UNSET', withEmail: true, withPhone: false },
|
|
430
|
+
{ tag: 'A4', label: 'victim email + phone UNSET (load-bearing)', withEmail: false, withPhone: false },
|
|
431
|
+
]
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------------------------
|
|
434
|
+
// G1 — Endpoint A: email/phone never written on a matched patient
|
|
435
|
+
// ---------------------------------------------------------------------------------------------
|
|
436
|
+
for (const shape of VICTIM_SHAPES) {
|
|
437
|
+
const name = `A blocks email/phone overwrite — ${shape.label}`
|
|
438
|
+
await runScenario('G1', name, async () => {
|
|
439
|
+
const victim = await makeVictim(shape.tag, {
|
|
440
|
+
...(shape.withEmail ? { email: emailFor(`lock-v-${shape.tag}`) } : {}),
|
|
441
|
+
...(shape.withPhone ? { phone: nextPhone() } : {}),
|
|
442
|
+
})
|
|
443
|
+
const attackerEmail = emailFor(`atk-${shape.tag}`)
|
|
444
|
+
const attackerPhone = nextPhone()
|
|
445
|
+
|
|
446
|
+
const session = await startForm({
|
|
447
|
+
fname: victim.fname,
|
|
448
|
+
lname: victim.lname,
|
|
449
|
+
dateOfBirth: victim.dateOfBirth,
|
|
450
|
+
email: attackerEmail,
|
|
451
|
+
phone: attackerPhone,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
|
|
455
|
+
// unchanged and the test would pass for the wrong reason.
|
|
456
|
+
if (session.enduserId !== victim.id) {
|
|
457
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
458
|
+
record('G1', name, false, `probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const { landed, enduser } = await pollEnduser(
|
|
463
|
+
victim.id,
|
|
464
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
465
|
+
OVERWRITE_WINDOW_MS,
|
|
466
|
+
)
|
|
467
|
+
record('G1', name, !landed, landed
|
|
468
|
+
? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
|
|
469
|
+
: `unchanged (${describe(enduser)})`
|
|
470
|
+
)
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------------------------
|
|
475
|
+
// G2 — Endpoint B: same rule. B returns only { authToken }, so probe validity is established by
|
|
476
|
+
// a run-unique lname that must resolve to exactly the victim.
|
|
477
|
+
// ---------------------------------------------------------------------------------------------
|
|
478
|
+
for (const shape of VICTIM_SHAPES) {
|
|
479
|
+
const name = `B blocks email/phone overwrite — ${shape.label}`
|
|
480
|
+
await runScenario('G2', name, async () => {
|
|
481
|
+
const victim = await makeVictim(`B${shape.tag}`, {
|
|
482
|
+
...(shape.withEmail ? { email: emailFor(`lock-vb-${shape.tag}`) } : {}),
|
|
483
|
+
...(shape.withPhone ? { phone: nextPhone() } : {}),
|
|
484
|
+
})
|
|
485
|
+
const attackerEmail = emailFor(`atkb-${shape.tag}`)
|
|
486
|
+
const attackerPhone = nextPhone()
|
|
487
|
+
|
|
488
|
+
await startBooking({
|
|
489
|
+
fname: victim.fname,
|
|
490
|
+
lname: victim.lname,
|
|
491
|
+
dateOfBirth: victim.dateOfBirth,
|
|
492
|
+
email: attackerEmail,
|
|
493
|
+
phone: attackerPhone,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
const matching = await sdk.api.endusers.getSome({ filter: { lname: victim.lname } })
|
|
497
|
+
if (matching.length !== 1 || matching[0].id !== victim.id) {
|
|
498
|
+
for (const m of matching) if (m.id !== victim.id) createdEnduserIds.push(m.id)
|
|
499
|
+
record('G2', name, false, `probe invalid — ${matching.length} records match lname ${victim.lname}; endpoint inserted instead of matching`)
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const { landed, enduser } = await pollEnduser(
|
|
504
|
+
victim.id,
|
|
505
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
506
|
+
AWAITED_WINDOW_MS,
|
|
507
|
+
)
|
|
508
|
+
record('G2', name, !landed, landed
|
|
509
|
+
? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
|
|
510
|
+
: `unchanged (${describe(enduser)})`
|
|
511
|
+
)
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------------------------
|
|
516
|
+
// G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
|
|
517
|
+
// as an attack through the first-party frontend, but trivially reachable by POSTing directly.
|
|
518
|
+
// The field lock must hold on this path too.
|
|
519
|
+
// ---------------------------------------------------------------------------------------------
|
|
520
|
+
await runScenario('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', async () => {
|
|
521
|
+
const victim = await makeVictim('C1') // no email, no phone — the takeover-ready shape
|
|
522
|
+
const attackerEmail = emailFor('atk-c1')
|
|
523
|
+
const attackerPhone = nextPhone()
|
|
524
|
+
|
|
525
|
+
const session = await startForm({ enduserId: victim.id, email: attackerEmail, phone: attackerPhone })
|
|
526
|
+
|
|
527
|
+
if (session.enduserId !== victim.id) {
|
|
528
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
529
|
+
record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', false,
|
|
530
|
+
`probe invalid — endpoint returned ${session.enduserId}, expected victim ${victim.id}`)
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const { landed, enduser } = await pollEnduser(
|
|
535
|
+
victim.id,
|
|
536
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
537
|
+
OVERWRITE_WINDOW_MS,
|
|
538
|
+
)
|
|
539
|
+
record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', !landed, landed
|
|
540
|
+
? `EXPLOIT: attacker values landed on victim (${describe(enduser)})`
|
|
541
|
+
: `unchanged (${describe(enduser)})`
|
|
542
|
+
)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------------------------
|
|
546
|
+
// G4 — The takeover chain itself.
|
|
547
|
+
//
|
|
548
|
+
// access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
|
|
549
|
+
// no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
|
|
550
|
+
// no-match and throttled alike — so it carries zero signal. Two sound substitutes:
|
|
551
|
+
// 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
|
|
552
|
+
// predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
|
|
553
|
+
// minting anything, so an empty set proves non-issuance.
|
|
554
|
+
// 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
|
|
555
|
+
// On a vulnerable server the now-email-bearing, password-less victim flips this to
|
|
556
|
+
// sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
|
|
557
|
+
// Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
|
|
558
|
+
// two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
|
|
559
|
+
// failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
|
|
560
|
+
// this file runs as part of tests.ts).
|
|
561
|
+
// ---------------------------------------------------------------------------------------------
|
|
562
|
+
await runScenario('G4', 'takeover chain: email-less victim is not made resolvable by A', async () => {
|
|
563
|
+
const victim = await makeVictim('D1', { phone: nextPhone() }) // email-less: the common, cheap case
|
|
564
|
+
const attackerEmail = emailFor('atk-d1')
|
|
565
|
+
|
|
566
|
+
const baseline = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
|
|
567
|
+
const baselineResult = baseline.data?.result
|
|
568
|
+
if (baseline.status === 429) {
|
|
569
|
+
skip('G4', 'begin_login_flow oracle baseline', 'rate limited (429) — oracle unavailable this run')
|
|
570
|
+
} else {
|
|
571
|
+
record('G4', 'begin_login_flow oracle baseline is continue-with-password',
|
|
572
|
+
baselineResult === 'continue-with-password', `result=${baselineResult} status=${baseline.status}`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const session = await startForm({
|
|
576
|
+
fname: victim.fname,
|
|
577
|
+
lname: victim.lname,
|
|
578
|
+
dateOfBirth: victim.dateOfBirth,
|
|
579
|
+
email: attackerEmail,
|
|
580
|
+
phone: nextPhone(),
|
|
581
|
+
})
|
|
582
|
+
if (session.enduserId !== victim.id) {
|
|
583
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
584
|
+
record('G4', 'takeover chain: email-less victim is not made resolvable by A', false,
|
|
585
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Burn the fire-and-forget window so a vulnerable server has every chance to land the write.
|
|
590
|
+
const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
|
|
591
|
+
|
|
592
|
+
// Informational only: 204 regardless of outcome.
|
|
593
|
+
const resetRes = await post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId })
|
|
594
|
+
console.log(` (info) request-enduser-password-reset -> HTTP ${resetRes.status} — unconditional, carries no signal`)
|
|
595
|
+
|
|
596
|
+
const resolvable = await sdk.api.endusers.getSome({ filter: { email: attackerEmail } })
|
|
597
|
+
record('G4', 'attacker email resolves to no enduser (password reset cannot be issued)',
|
|
598
|
+
resolvable.length === 0,
|
|
599
|
+
resolvable.length === 0
|
|
600
|
+
? `no enduser carries ${attackerEmail} (victim still ${describe(enduser)})`
|
|
601
|
+
: `EXPLOIT: ${resolvable.length} enduser(s) now resolve for ${attackerEmail} — reset token would be minted and mailed to the attacker`
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
const after = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
|
|
605
|
+
const afterResult = after.data?.result
|
|
606
|
+
if (after.status === 429) {
|
|
607
|
+
skip('G4', 'begin_login_flow oracle after attack', 'rate limited (429) — oracle unavailable this run')
|
|
608
|
+
} else {
|
|
609
|
+
record('G4', 'begin_login_flow still returns continue-with-password after attack',
|
|
610
|
+
afterResult === 'continue-with-password',
|
|
611
|
+
afterResult === 'continue-with-password'
|
|
612
|
+
? `result=${afterResult}`
|
|
613
|
+
: `EXPLOIT: result=${afterResult} — an OTP / magic link was dispatched for the attacker's address`
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------------------------
|
|
619
|
+
// G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
|
|
620
|
+
// ---------------------------------------------------------------------------------------------
|
|
621
|
+
await runScenario('G5', 'blank match keys filled on match, blank phone NOT filled', async () => {
|
|
622
|
+
const victimEmail = emailFor('lock-v-e1')
|
|
623
|
+
// Matched via the email branch, so the record can start with blank names. Under
|
|
624
|
+
// matchEmailAndNames that branch also requires fname + lname, so fname/lname must be present
|
|
625
|
+
// for the match to happen at all — coverage narrows to dateOfBirth/gender in that config.
|
|
626
|
+
const victim = await sdk.api.endusers.createOne({
|
|
627
|
+
email: victimEmail,
|
|
628
|
+
...(MATCH_EMAIL_AND_NAMES ? { fname: `Lock${RUN}`, lname: `Victim${RUN}E1` } : {}),
|
|
629
|
+
} as any)
|
|
630
|
+
createdEnduserIds.push(victim.id)
|
|
631
|
+
if (MATCH_EMAIL_AND_NAMES) {
|
|
632
|
+
skip('G5', 'blank fname/lname fill', 'organization has settings.endusers.matchEmailAndNames — a name-less record is unmatchable')
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const submittedPhone = nextPhone()
|
|
636
|
+
const session = await startForm({
|
|
637
|
+
email: victimEmail,
|
|
638
|
+
fname: `Lock${RUN}`,
|
|
639
|
+
lname: `Victim${RUN}E1`,
|
|
640
|
+
dateOfBirth: '03-04-1991',
|
|
641
|
+
gender: 'Female',
|
|
642
|
+
phone: submittedPhone,
|
|
643
|
+
})
|
|
644
|
+
if (session.enduserId !== victim.id) {
|
|
645
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
646
|
+
record('G5', 'blank match keys filled on match, blank phone NOT filled', false,
|
|
647
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const filled = await pollEnduser(
|
|
652
|
+
victim.id,
|
|
653
|
+
e => !!e.dateOfBirth && !!e.gender && (MATCH_EMAIL_AND_NAMES || (!!e.fname && !!e.lname)),
|
|
654
|
+
OVERWRITE_WINDOW_MS,
|
|
655
|
+
)
|
|
656
|
+
record('G5', 'blank fname/lname/dateOfBirth/gender still filled on match', filled.landed,
|
|
657
|
+
`fname=${filled.enduser?.fname || '(unset)'} lname=${filled.enduser?.lname || '(unset)'} `
|
|
658
|
+
+ `dateOfBirth=${filled.enduser?.dateOfBirth || '(unset)'} gender=${filled.enduser?.gender || '(unset)'}`)
|
|
659
|
+
|
|
660
|
+
const phoneLanded = await pollEnduser(victim.id, e => !!e.phone, AWAITED_WINDOW_MS)
|
|
661
|
+
record('G5', 'blank phone still NOT filled on match', !phoneLanded.landed,
|
|
662
|
+
phoneLanded.landed
|
|
663
|
+
? `EXPLOIT: phone=${phoneLanded.enduser?.phone} written into a blank field (submitted ${submittedPhone})`
|
|
664
|
+
: 'phone remains unset')
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
// ---------------------------------------------------------------------------------------------
|
|
668
|
+
// G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
|
|
669
|
+
// state is not an auth channel, and locking it would break a relocated patient booking with a
|
|
670
|
+
// provider licensed in their new state.
|
|
671
|
+
// ---------------------------------------------------------------------------------------------
|
|
672
|
+
await runScenario('G6', 'state still overwrites on match (status-quo pin)', async () => {
|
|
673
|
+
const victim = await makeVictim('F1', { email: emailFor('lock-v-f1'), state: 'NY' })
|
|
674
|
+
|
|
675
|
+
const session = await startForm({
|
|
676
|
+
fname: victim.fname,
|
|
677
|
+
lname: victim.lname,
|
|
678
|
+
dateOfBirth: victim.dateOfBirth,
|
|
679
|
+
phone: nextPhone(),
|
|
680
|
+
state: 'CA',
|
|
681
|
+
})
|
|
682
|
+
if (session.enduserId !== victim.id) {
|
|
683
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
684
|
+
record('G6', 'state still overwrites on match (status-quo pin)', false,
|
|
685
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const { landed, enduser } = await pollEnduser(victim.id, e => e.state === 'CA', OVERWRITE_WINDOW_MS)
|
|
690
|
+
record('G6', 'state still overwrites on match (status-quo pin)', landed, `state=${enduser?.state || '(unset)'}`)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------------------------
|
|
694
|
+
// G7 — Insert path unchanged: a brand-new visitor is still created with the submitted email/phone.
|
|
695
|
+
// ---------------------------------------------------------------------------------------------
|
|
696
|
+
// Accumulator arrays rather than `let ... | null`: values are assigned inside runScenario's
|
|
697
|
+
// callback, and TS's control-flow narrowing does not account for closure assignments.
|
|
698
|
+
const formSessions: { accessCode: string, authToken: string, enduserId: string }[] = []
|
|
699
|
+
await runScenario('G7', 'A creates a new visitor with submitted email + phone', async () => {
|
|
700
|
+
const email = emailFor('lock-new-a')
|
|
701
|
+
const phone = nextPhone()
|
|
702
|
+
const session = await startForm({
|
|
703
|
+
fname: `Lock${RUN}`, lname: `NewA${RUN}`, dateOfBirth: '05-06-1992', email, phone,
|
|
704
|
+
})
|
|
705
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
706
|
+
|
|
707
|
+
const created = await sdk.api.endusers.getOne(session.enduserId)
|
|
708
|
+
const ok = created.email === email && created.phone === phone
|
|
709
|
+
&& !!session.accessCode && !!session.authToken && !!session.enduserId
|
|
710
|
+
record('G7', 'A creates a new visitor with submitted email + phone', ok,
|
|
711
|
+
`${describe(created)}; accessCode=${!!session.accessCode} authToken=${!!session.authToken} enduserId=${!!session.enduserId}`)
|
|
712
|
+
|
|
713
|
+
if (ok) formSessions.push(session)
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
const bookingTokens: string[] = []
|
|
717
|
+
await runScenario('G7', 'B creates a new visitor with submitted email + phone', async () => {
|
|
718
|
+
const email = emailFor('lock-new-b')
|
|
719
|
+
const phone = nextPhone()
|
|
720
|
+
const lname = `NewB${RUN}`
|
|
721
|
+
const session = await startBooking({
|
|
722
|
+
fname: `Lock${RUN}`, lname, dateOfBirth: '07-08-1993', email, phone,
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
const matching = await sdk.api.endusers.getSome({ filter: { lname } })
|
|
726
|
+
for (const m of matching) createdEnduserIds.push(m.id)
|
|
727
|
+
|
|
728
|
+
const created = matching.length === 1 ? matching[0] : null
|
|
729
|
+
const ok = !!session.authToken && !!created && created.email === email && created.phone === phone
|
|
730
|
+
record('G7', 'B creates a new visitor with submitted email + phone', ok,
|
|
731
|
+
`authToken=${!!session.authToken}; ${matching.length} record(s); ${describe(created)}`)
|
|
732
|
+
|
|
733
|
+
if (session.authToken) bookingTokens.push(session.authToken)
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------------------------
|
|
737
|
+
// G8 — The returned public-form session still works end-to-end.
|
|
738
|
+
// ---------------------------------------------------------------------------------------------
|
|
739
|
+
await runScenario('G8', 'form submission completes under the returned token', async () => {
|
|
740
|
+
const session = formSessions[0]
|
|
741
|
+
if (!session) {
|
|
742
|
+
record('G8', 'form submission completes under the returned token', false, 'no session available from G7')
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
const authed = new EnduserSession({ host, businessId, authToken: session.authToken })
|
|
746
|
+
const { formResponse } = await authed.api.form_responses.submit_form_response({
|
|
747
|
+
accessCode: session.accessCode,
|
|
748
|
+
responses: [{
|
|
749
|
+
fieldId: plainField.id,
|
|
750
|
+
fieldTitle: plainField.title,
|
|
751
|
+
answer: { type: 'string' as const, value: `lock test ${RUN}` },
|
|
752
|
+
}],
|
|
753
|
+
})
|
|
754
|
+
record('G8', 'form submission completes under the returned token', !!formResponse?.submittedAt,
|
|
755
|
+
`formResponse=${formResponse?.id} submittedAt=${formResponse?.submittedAt ? 'set' : '(unset)'}`)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
// ---------------------------------------------------------------------------------------------
|
|
759
|
+
// G9 — The returned booking session still works. Tiered: availability is always asserted; an
|
|
760
|
+
// actual booking is attempted only when a slot exists, so an empty window never reads as a
|
|
761
|
+
// regression.
|
|
762
|
+
// ---------------------------------------------------------------------------------------------
|
|
763
|
+
await runScenario('G9', 'availability + booking work under the returned session', async () => {
|
|
764
|
+
const bookingAuthToken = bookingTokens[0]
|
|
765
|
+
if (!bookingAuthToken) {
|
|
766
|
+
record('G9', 'availability + booking work under the returned session', false, 'no booking session available from G7')
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
const booking = new EnduserSession({ host, businessId, authToken: bookingAuthToken })
|
|
770
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
|
771
|
+
const from = new Date(Date.now() + DAY_IN_MS)
|
|
772
|
+
const to = new Date(from.getTime() + (7 * DAY_IN_MS))
|
|
773
|
+
|
|
774
|
+
const { availabilityBlocks } = await booking.api.calendar_events.get_appointment_availability({
|
|
775
|
+
calendarEventTemplateId: template.id,
|
|
776
|
+
userIds: [hostUser.id],
|
|
777
|
+
restrictedByState: false, // the public booking enduser has no state
|
|
778
|
+
from,
|
|
779
|
+
to,
|
|
780
|
+
})
|
|
781
|
+
const slots = availabilityBlocks.filter(b => b.userId === hostUser.id && b.startTimeInMS > Date.now())
|
|
782
|
+
record('G9', 'get_appointment_availability succeeds under the confined booking token', true,
|
|
783
|
+
`${availabilityBlocks.length} block(s), ${slots.length} future slot(s) for the throwaway host`)
|
|
784
|
+
|
|
785
|
+
if (slots.length === 0) {
|
|
786
|
+
skip('G9', 'book_appointment under the confined booking token', 'no future availability slot in the 7-day window')
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const { createdEvent } = await booking.api.calendar_events.book_appointment({
|
|
791
|
+
userId: hostUser.id,
|
|
792
|
+
calendarEventTemplateId: template.id,
|
|
793
|
+
startTime: new Date(slots[0].startTimeInMS),
|
|
794
|
+
timezone: 'America/New_York',
|
|
795
|
+
})
|
|
796
|
+
if (createdEvent?.id) createdEventIds.push(createdEvent.id)
|
|
797
|
+
record('G9', 'book_appointment succeeds under the confined booking token', !!createdEvent?.id,
|
|
798
|
+
`createdEvent=${createdEvent?.id}`)
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------------------------------------
|
|
802
|
+
// G10 — skipMatch behavior unchanged.
|
|
803
|
+
// ---------------------------------------------------------------------------------------------
|
|
804
|
+
await runScenario('G10', 'skipMatch=true still inserts a new record', async () => {
|
|
805
|
+
const victim = await makeVictim('H1', { email: emailFor('lock-v-h1'), phone: nextPhone() })
|
|
806
|
+
|
|
807
|
+
const session = await startForm({
|
|
808
|
+
fname: victim.fname,
|
|
809
|
+
lname: victim.lname,
|
|
810
|
+
dateOfBirth: victim.dateOfBirth, // would otherwise match the victim
|
|
811
|
+
email: emailFor('lock-skip'),
|
|
812
|
+
phone: nextPhone(),
|
|
813
|
+
skipMatch: true,
|
|
814
|
+
})
|
|
815
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
816
|
+
record('G10', 'skipMatch=true still inserts a new record', session.enduserId !== victim.id,
|
|
817
|
+
`inserted=${session.enduserId} victim=${victim.id}`)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
await runScenario('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', async () => {
|
|
821
|
+
const session = await publicSDK().api.form_responses.session_for_public_form({
|
|
822
|
+
formId: autoMergeForm.id,
|
|
823
|
+
businessId,
|
|
824
|
+
skipMatch: true,
|
|
825
|
+
fname: `Lock${RUN}`,
|
|
826
|
+
lname: `Merge${RUN}`,
|
|
827
|
+
dateOfBirth: '09-10-1994',
|
|
828
|
+
email: emailFor('lock-merge'),
|
|
829
|
+
phone: nextPhone(),
|
|
830
|
+
})
|
|
831
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
832
|
+
|
|
833
|
+
const created = await sdk.api.endusers.getOne(session.enduserId)
|
|
834
|
+
record('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge',
|
|
835
|
+
created.eligibleForAutoMerge === true, `eligibleForAutoMerge=${created.eligibleForAutoMerge}`)
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// ---------------------------------------------------------------------------------------------
|
|
839
|
+
// G11 — Intake answers must not overwrite email/phone on a matched patient (submit path).
|
|
840
|
+
// ---------------------------------------------------------------------------------------------
|
|
841
|
+
for (const shape of VICTIM_SHAPES) {
|
|
842
|
+
const name = `intake answers do not overwrite a matched patient — ${shape.label}`
|
|
843
|
+
await runScenario('G11', name, async () => {
|
|
844
|
+
const victimEmail = shape.withEmail ? emailFor(`lock-i-${shape.tag}`) : undefined
|
|
845
|
+
const victimPhone = shape.withPhone ? nextPhone() : undefined
|
|
846
|
+
const victim = await makeVictim(`I${shape.tag}`, {
|
|
847
|
+
...(victimEmail ? { email: victimEmail } : {}),
|
|
848
|
+
...(victimPhone ? { phone: victimPhone } : {}),
|
|
849
|
+
})
|
|
850
|
+
const attackerEmail = emailFor(`atk-i-${shape.tag}`)
|
|
851
|
+
const attackerPhone = nextPhone()
|
|
852
|
+
|
|
853
|
+
// No email/phone passed to the endpoint at all, so the intake answers below are the only
|
|
854
|
+
// possible source of a change to the victim's contact info.
|
|
855
|
+
const session = await startFormOn(intake.form.id, {
|
|
856
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
857
|
+
})
|
|
858
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
859
|
+
if (session.enduserId !== victim.id) {
|
|
860
|
+
record('G11', name, false,
|
|
861
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
866
|
+
accessCode: session.accessCode,
|
|
867
|
+
responses: intakeAnswers(intake, attackerEmail, attackerPhone),
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
const { enduser } = await pollEnduser(
|
|
871
|
+
victim.id,
|
|
872
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
873
|
+
OVERWRITE_WINDOW_MS,
|
|
874
|
+
)
|
|
875
|
+
const emailHeld = (enduser?.email || undefined) === victimEmail
|
|
876
|
+
const phoneHeld = (enduser?.phone || undefined) === victimPhone
|
|
877
|
+
record('G11', `${name} — email`, emailHeld,
|
|
878
|
+
emailHeld
|
|
879
|
+
? `email still ${victimEmail || '(unset)'}`
|
|
880
|
+
: `EXPLOIT: intake answer wrote ${enduser?.email} (was ${victimEmail || '(unset)'})`)
|
|
881
|
+
record('G11', `${name} — phone`, phoneHeld,
|
|
882
|
+
phoneHeld
|
|
883
|
+
? `phone still ${victimPhone || '(unset)'}`
|
|
884
|
+
: `EXPLOIT: intake answer wrote ${enduser?.phone} (was ${victimPhone || '(unset)'})`)
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ---------------------------------------------------------------------------------------------
|
|
889
|
+
// G12 — Same bypass via save_field_response, without ever submitting the form.
|
|
890
|
+
// ---------------------------------------------------------------------------------------------
|
|
891
|
+
const G12_NAME = 'saveIntakeOnPartial answers do not overwrite a matched patient'
|
|
892
|
+
await runScenario('G12', G12_NAME, async () => {
|
|
893
|
+
const victim = await makeVictim('J1', { phone: nextPhone() }) // email-less: the load-bearing shape
|
|
894
|
+
const attackerEmail = emailFor('atk-j1')
|
|
895
|
+
|
|
896
|
+
const session = await startFormOn(partialIntake.form.id, {
|
|
897
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
898
|
+
})
|
|
899
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
900
|
+
if (session.enduserId !== victim.id) {
|
|
901
|
+
record('G12', G12_NAME, false,
|
|
902
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
|
|
907
|
+
// triggers the intake write.
|
|
908
|
+
await asEnduser(session.authToken).api.form_responses.save_field_response({
|
|
909
|
+
accessCode: session.accessCode,
|
|
910
|
+
response: {
|
|
911
|
+
fieldId: partialIntake.emailField.id,
|
|
912
|
+
fieldTitle: partialIntake.emailField.title,
|
|
913
|
+
answer: { type: 'email' as const, value: attackerEmail },
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
|
|
918
|
+
record('G12', G12_NAME, !enduser?.email,
|
|
919
|
+
!enduser?.email
|
|
920
|
+
? 'email still (unset) after partial save'
|
|
921
|
+
: `EXPLOIT: partial save wrote ${enduser?.email} without ever submitting the form`)
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------------------------
|
|
925
|
+
// G13 — The full takeover chain, driven through an intake answer instead of the endpoint.
|
|
926
|
+
// ---------------------------------------------------------------------------------------------
|
|
927
|
+
const G13_NAME = 'takeover chain via intake answer: victim not made resolvable'
|
|
928
|
+
await runScenario('G13', G13_NAME, async () => {
|
|
929
|
+
const victim = await makeVictim('K1', { phone: nextPhone() })
|
|
930
|
+
const attackerEmail = emailFor('atk-k1')
|
|
931
|
+
|
|
932
|
+
const session = await startFormOn(intake.form.id, {
|
|
933
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
934
|
+
})
|
|
935
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
936
|
+
if (session.enduserId !== victim.id) {
|
|
937
|
+
record('G13', G13_NAME, false,
|
|
938
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
943
|
+
accessCode: session.accessCode,
|
|
944
|
+
responses: intakeAnswers(intake, attackerEmail, nextPhone()),
|
|
945
|
+
})
|
|
946
|
+
const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
|
|
947
|
+
|
|
948
|
+
// Informational only: 204 regardless of outcome.
|
|
949
|
+
const resetRes = await post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId })
|
|
950
|
+
console.log(` (info) request-enduser-password-reset -> HTTP ${resetRes.status} — unconditional, carries no signal`)
|
|
951
|
+
|
|
952
|
+
const resolvable = await sdk.api.endusers.getSome({ filter: { email: attackerEmail } })
|
|
953
|
+
record('G13', 'attacker email resolves to no enduser after intake submit',
|
|
954
|
+
resolvable.length === 0,
|
|
955
|
+
resolvable.length === 0
|
|
956
|
+
? `no enduser carries ${attackerEmail} (victim still ${describe(enduser)})`
|
|
957
|
+
: `EXPLOIT: ${resolvable.length} enduser(s) now resolve for ${attackerEmail} — reset token would be mailed to the attacker`)
|
|
958
|
+
|
|
959
|
+
const after = await post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId })
|
|
960
|
+
const afterResult = after.data?.result
|
|
961
|
+
if (after.status === 429) {
|
|
962
|
+
skip('G13', 'begin_login_flow oracle after intake attack', 'rate limited (429) — oracle unavailable this run')
|
|
963
|
+
} else {
|
|
964
|
+
record('G13', 'begin_login_flow still returns continue-with-password after intake attack',
|
|
965
|
+
afterResult === 'continue-with-password',
|
|
966
|
+
afterResult === 'continue-with-password'
|
|
967
|
+
? `result=${afterResult}`
|
|
968
|
+
: `EXPLOIT: result=${afterResult} — an OTP / magic link was dispatched for the attacker's address`)
|
|
969
|
+
}
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
// ---------------------------------------------------------------------------------------------
|
|
973
|
+
// G14 — Pivot: a public session can prepare a response for a NON-public form and submit that.
|
|
974
|
+
// ---------------------------------------------------------------------------------------------
|
|
975
|
+
const G14_NAME = 'pivot via prepare_form_response to a non-public form is also blocked'
|
|
976
|
+
await runScenario('G14', G14_NAME, async () => {
|
|
977
|
+
const victimPhone = nextPhone()
|
|
978
|
+
const victim = await makeVictim('L1', { phone: victimPhone })
|
|
979
|
+
const attackerEmail = emailFor('atk-l1')
|
|
980
|
+
const attackerPhone = nextPhone()
|
|
981
|
+
|
|
982
|
+
const session = await startFormOn(intake.form.id, {
|
|
983
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
984
|
+
})
|
|
985
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
986
|
+
if (session.enduserId !== victim.id) {
|
|
987
|
+
record('G14', G14_NAME, false,
|
|
988
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const authed = asEnduser(session.authToken)
|
|
993
|
+
// prepare_form_response is in the 'public-form' session scope and does not require allowPublicURL.
|
|
994
|
+
// enduserId === session id, so its ownership check passes.
|
|
995
|
+
const prepared = await authed.api.form_responses.prepare_form_response({
|
|
996
|
+
formId: pivotIntake.form.id,
|
|
997
|
+
enduserId: victim.id,
|
|
998
|
+
})
|
|
999
|
+
await authed.api.form_responses.submit_form_response({
|
|
1000
|
+
accessCode: prepared.accessCode,
|
|
1001
|
+
responses: intakeAnswers(pivotIntake, attackerEmail, attackerPhone),
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
const { enduser } = await pollEnduser(
|
|
1005
|
+
victim.id,
|
|
1006
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
1007
|
+
OVERWRITE_WINDOW_MS,
|
|
1008
|
+
)
|
|
1009
|
+
record('G14', `${G14_NAME} — email`, !enduser?.email,
|
|
1010
|
+
!enduser?.email
|
|
1011
|
+
? 'email still (unset) after pivot submit'
|
|
1012
|
+
: `EXPLOIT: pivot to a non-public form wrote ${enduser?.email}`)
|
|
1013
|
+
record('G14', `${G14_NAME} — phone`, enduser?.phone === victimPhone,
|
|
1014
|
+
enduser?.phone === victimPhone
|
|
1015
|
+
? `phone still ${victimPhone}`
|
|
1016
|
+
: `EXPLOIT: pivot to a non-public form wrote ${enduser?.phone} (was ${victimPhone})`)
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
// ---------------------------------------------------------------------------------------------
|
|
1020
|
+
// G15 — save_field_response ownership: a guessable formResponseId must not reach another patient.
|
|
1021
|
+
// ---------------------------------------------------------------------------------------------
|
|
1022
|
+
const G15_NAME = "save_field_response rejects another patient's formResponseId"
|
|
1023
|
+
await runScenario('G15', G15_NAME, async () => {
|
|
1024
|
+
const patientA = await makeVictim('M1', { phone: nextPhone() })
|
|
1025
|
+
const patientB = await makeVictim('M2', { phone: nextPhone() })
|
|
1026
|
+
|
|
1027
|
+
const sessionA = await startFormOn(partialIntake.form.id, {
|
|
1028
|
+
fname: patientA.fname, lname: patientA.lname, dateOfBirth: patientA.dateOfBirth,
|
|
1029
|
+
})
|
|
1030
|
+
if (sessionA.enduserId) createdEnduserIds.push(sessionA.enduserId)
|
|
1031
|
+
if (sessionA.enduserId !== patientA.id) {
|
|
1032
|
+
record('G15', G15_NAME, false,
|
|
1033
|
+
`probe invalid — endpoint inserted ${sessionA.enduserId} instead of matching patient A ${patientA.id}`)
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Patient B's in-progress response, created by staff.
|
|
1038
|
+
const bPrepared = await sdk.api.form_responses.prepare_form_response({
|
|
1039
|
+
formId: partialIntake.form.id, enduserId: patientB.id,
|
|
1040
|
+
})
|
|
1041
|
+
const bResponseId = (bPrepared.response as any)?.id ?? (bPrepared.response as any)?._id
|
|
1042
|
+
|
|
1043
|
+
const answer = {
|
|
1044
|
+
fieldId: partialIntake.emailField.id,
|
|
1045
|
+
fieldTitle: partialIntake.emailField.title,
|
|
1046
|
+
answer: { type: 'email' as const, value: emailFor('atk-m1') },
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const byId = await patchAuthed(
|
|
1050
|
+
'/v1/save-field-response',
|
|
1051
|
+
{ formResponseId: bResponseId, response: answer },
|
|
1052
|
+
sessionA.authToken,
|
|
1053
|
+
)
|
|
1054
|
+
record('G15', G15_NAME, byId.status === 403,
|
|
1055
|
+
byId.status === 403
|
|
1056
|
+
? 'HTTP 403 as expected'
|
|
1057
|
+
: `EXPLOIT: HTTP ${byId.status} — patient A wrote into patient B's in-progress response`)
|
|
1058
|
+
|
|
1059
|
+
// The accessCode path stays open by design: the code is a 32-byte secret, so holding it IS the
|
|
1060
|
+
// authorization (same model as submit_form_response). Pinned so the distinction is deliberate.
|
|
1061
|
+
const byCode = await patchAuthed(
|
|
1062
|
+
'/v1/save-field-response',
|
|
1063
|
+
{ accessCode: bPrepared.accessCode, response: answer },
|
|
1064
|
+
sessionA.authToken,
|
|
1065
|
+
)
|
|
1066
|
+
record('G15', 'save_field_response still accepts a known accessCode', byCode.status === 200,
|
|
1067
|
+
`HTTP ${byCode.status}`)
|
|
1068
|
+
|
|
1069
|
+
// A guessed formResponseId paired with a WRONG accessCode must still be rejected — otherwise
|
|
1070
|
+
// supplying any junk code would defeat the ownership check.
|
|
1071
|
+
const byIdWrongCode = await patchAuthed(
|
|
1072
|
+
'/v1/save-field-response',
|
|
1073
|
+
{ formResponseId: bResponseId, accessCode: 'not-a-real-access-code', response: answer },
|
|
1074
|
+
sessionA.authToken,
|
|
1075
|
+
)
|
|
1076
|
+
record('G15', "another patient's formResponseId + wrong accessCode is rejected",
|
|
1077
|
+
byIdWrongCode.status === 403, `HTTP ${byIdWrongCode.status}`)
|
|
1078
|
+
|
|
1079
|
+
// THE REAL UI SHAPE: the form hooks always send accessCode AND formResponseId together
|
|
1080
|
+
// (react/components/src/Forms/hooks.tsx), and formResponseId wins the server-side lookup. Patient
|
|
1081
|
+
// A saving their OWN in-progress response this way must keep working — a naive
|
|
1082
|
+
// "reject whenever formResponseId is present" guard would break every live form.
|
|
1083
|
+
const [aResponse] = await sdk.api.form_responses.getSome({ filter: { accessCode: sessionA.accessCode } })
|
|
1084
|
+
const ownBoth = await patchAuthed(
|
|
1085
|
+
'/v1/save-field-response',
|
|
1086
|
+
{
|
|
1087
|
+
formResponseId: aResponse?.id,
|
|
1088
|
+
accessCode: sessionA.accessCode,
|
|
1089
|
+
response: {
|
|
1090
|
+
fieldId: partialIntake.emailField.id,
|
|
1091
|
+
fieldTitle: partialIntake.emailField.title,
|
|
1092
|
+
answer: { type: 'email' as const, value: emailFor('own-both') },
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
sessionA.authToken,
|
|
1096
|
+
)
|
|
1097
|
+
record('G15', 'own response with accessCode + formResponseId still succeeds (real UI shape)',
|
|
1098
|
+
ownBoth.status === 200,
|
|
1099
|
+
ownBoth.status === 200 ? 'HTTP 200' : `REGRESSION: HTTP ${ownBoth.status} — this is how the form UI calls it`)
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
// ---------------------------------------------------------------------------------------------
|
|
1103
|
+
// G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
|
|
1104
|
+
// This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
|
|
1105
|
+
// scoped to "matched a pre-existing patient" rather than "any public session".
|
|
1106
|
+
// ---------------------------------------------------------------------------------------------
|
|
1107
|
+
await runScenario('G16', 'brand-new visitor still gets email/phone from intake answers', async () => {
|
|
1108
|
+
const submittedEmail = emailFor('lock-new-intake')
|
|
1109
|
+
const submittedPhone = nextPhone()
|
|
1110
|
+
|
|
1111
|
+
const session = await startFormOn(intake.form.id, {
|
|
1112
|
+
fname: `Lock${RUN}`, lname: `NewIntake${RUN}`, dateOfBirth: '05-06-1992',
|
|
1113
|
+
})
|
|
1114
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1115
|
+
|
|
1116
|
+
const created = await sdk.api.endusers.getOne(session.enduserId)
|
|
1117
|
+
record('G16', 'new visitor starts with no contact info from the endpoint',
|
|
1118
|
+
!created.email && !created.phone, describe(created))
|
|
1119
|
+
|
|
1120
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1121
|
+
accessCode: session.accessCode,
|
|
1122
|
+
responses: intakeAnswers(intake, submittedEmail, submittedPhone),
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
const { enduser } = await pollEnduser(session.enduserId, e => !!e.email && !!e.phone, AWAITED_WINDOW_MS)
|
|
1126
|
+
record('G16', 'brand-new visitor still gets email from intake answers',
|
|
1127
|
+
enduser?.email === submittedEmail, `email=${enduser?.email || '(unset)'} expected=${submittedEmail}`)
|
|
1128
|
+
record('G16', 'brand-new visitor still gets phone from intake answers',
|
|
1129
|
+
enduser?.phone === submittedPhone, `phone=${enduser?.phone || '(unset)'} expected=${submittedPhone}`)
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
// ---------------------------------------------------------------------------------------------
|
|
1133
|
+
// G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
|
|
1134
|
+
// ---------------------------------------------------------------------------------------------
|
|
1135
|
+
await runScenario('G17', 'authenticated portal enduser can still self-update via intake', async () => {
|
|
1136
|
+
const patient = await makeVictim('N1', { email: emailFor('lock-n1'), phone: nextPhone() })
|
|
1137
|
+
const newEmail = emailFor('lock-n1-new')
|
|
1138
|
+
const newPhone = nextPhone()
|
|
1139
|
+
|
|
1140
|
+
// generate_auth_token rather than .authenticate — repeated logins hit the rate limiter.
|
|
1141
|
+
const portalSDK = new EnduserSession({ host, businessId })
|
|
1142
|
+
await authenticate_enduser_via_token(sdk, portalSDK, { id: patient.id })
|
|
1143
|
+
|
|
1144
|
+
const prepared = await sdk.api.form_responses.prepare_form_response({
|
|
1145
|
+
formId: intake.form.id, enduserId: patient.id,
|
|
1146
|
+
})
|
|
1147
|
+
await portalSDK.api.form_responses.submit_form_response({
|
|
1148
|
+
accessCode: prepared.accessCode,
|
|
1149
|
+
responses: intakeAnswers(intake, newEmail, newPhone),
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
const { enduser } = await pollEnduser(patient.id, e => e.email === newEmail, AWAITED_WINDOW_MS)
|
|
1153
|
+
record('G17', 'authenticated portal intake still updates email', enduser?.email === newEmail,
|
|
1154
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
1155
|
+
record('G17', 'authenticated portal intake still updates phone', enduser?.phone === newPhone,
|
|
1156
|
+
`phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
// ---------------------------------------------------------------------------------------------
|
|
1160
|
+
// G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
|
|
1161
|
+
// ---------------------------------------------------------------------------------------------
|
|
1162
|
+
await runScenario('G18', 'staff intake submission still updates email/phone', async () => {
|
|
1163
|
+
const patient = await makeVictim('O1', { email: emailFor('lock-o1'), phone: nextPhone() })
|
|
1164
|
+
const newEmail = emailFor('lock-o1-new')
|
|
1165
|
+
const newPhone = nextPhone()
|
|
1166
|
+
|
|
1167
|
+
const prepared = await sdk.api.form_responses.prepare_form_response({
|
|
1168
|
+
formId: intake.form.id, enduserId: patient.id,
|
|
1169
|
+
})
|
|
1170
|
+
await sdk.api.form_responses.submit_form_response({
|
|
1171
|
+
accessCode: prepared.accessCode,
|
|
1172
|
+
responses: intakeAnswers(intake, newEmail, newPhone),
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
const { enduser } = await pollEnduser(patient.id, e => e.email === newEmail, AWAITED_WINDOW_MS)
|
|
1176
|
+
record('G18', 'staff intake submission still updates email', enduser?.email === newEmail,
|
|
1177
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
1178
|
+
record('G18', 'staff intake submission still updates phone', enduser?.phone === newPhone,
|
|
1179
|
+
`phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
// ---------------------------------------------------------------------------------------------
|
|
1183
|
+
// G19 / G20 — Cross-patient session via a null fname/lname match.
|
|
1184
|
+
//
|
|
1185
|
+
// In both handlers' $or, `email` and `dateOfBirth` get `|| "never"` sentinels but fname/lname are
|
|
1186
|
+
// interpolated RAW. `ignoreUndefined` is not set on the Mongo client (database.ts connect options;
|
|
1187
|
+
// driver 5.7.0 defaults it to false), and findOne does not strip undefined keys — so an absent
|
|
1188
|
+
// fname serializes to `{fname: null}`, which matches every document where the field is null OR
|
|
1189
|
+
// MISSING. fname/lname have no schema initializer, so a cold-started enduser has them absent.
|
|
1190
|
+
//
|
|
1191
|
+
// Net: a request supplying ONLY dateOfBirth queries {fname: null, lname: null, dateOfBirth: X} and
|
|
1192
|
+
// can match another patient, returning a session scoped to THEIR record. The cold-start pattern is
|
|
1193
|
+
// what manufactures nameless endusers, and a later intakeField: 'dateOfBirth' question fills the DOB.
|
|
1194
|
+
// ---------------------------------------------------------------------------------------------
|
|
1195
|
+
|
|
1196
|
+
// dateOfBirth is only string-validated and the handlers merely trim it, so a run-tokenized value is
|
|
1197
|
+
// accepted. Deliberately opaque rather than date-shaped, so match-vs-insert is unambiguous even when
|
|
1198
|
+
// this file runs inside the full suite (no reset_db between files).
|
|
1199
|
+
const namelessDob = `01-02-1990-${RUN}`
|
|
1200
|
+
|
|
1201
|
+
const makeNamelessVictim = async (tag: string) => {
|
|
1202
|
+
// No fname/lname at all — those fields have no initializer, so the keys stay ABSENT (not ''),
|
|
1203
|
+
// which is precisely what {fname: null} null-matches.
|
|
1204
|
+
const victim = await sdk.api.endusers.createOne({ dateOfBirth: `${namelessDob}-${tag}` } as any)
|
|
1205
|
+
createdEnduserIds.push(victim.id)
|
|
1206
|
+
const stored = await sdk.api.endusers.getOne(victim.id)
|
|
1207
|
+
return { victim, stored }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const G19_NAME = 'DOB-only request does not hijack a nameless patient (form endpoint)'
|
|
1211
|
+
await runScenario('G19', G19_NAME, async () => {
|
|
1212
|
+
const { victim, stored } = await makeNamelessVictim('f')
|
|
1213
|
+
|
|
1214
|
+
// Probe validity: a stored '' would not be null-matched and would make this test vacuous.
|
|
1215
|
+
record('G19', 'nameless victim really has absent fname/lname (probe validity)',
|
|
1216
|
+
stored.fname === undefined && stored.lname === undefined,
|
|
1217
|
+
`fname=${JSON.stringify(stored.fname)} lname=${JSON.stringify(stored.lname)}`)
|
|
1218
|
+
|
|
1219
|
+
// Only dateOfBirth. No names, no email/phone, no skipMatch. Reachable because the intake fixture
|
|
1220
|
+
// sets intakePhone: 'hidden', which satisfies the endpoint's phone guard.
|
|
1221
|
+
const session = await startFormOn(intake.form.id, { dateOfBirth: stored.dateOfBirth })
|
|
1222
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1223
|
+
|
|
1224
|
+
record('G19', G19_NAME, session.enduserId !== victim.id,
|
|
1225
|
+
session.enduserId !== victim.id
|
|
1226
|
+
? `inserted a new record (${session.enduserId}) instead of matching the victim`
|
|
1227
|
+
: `EXPLOIT: returned a session for the victim's record ${victim.id} from a DOB-only request`)
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
const G20_NAME = 'DOB-only request does not hijack a nameless patient (booking endpoint)'
|
|
1231
|
+
await runScenario('G20', G20_NAME, async () => {
|
|
1232
|
+
const { victim, stored } = await makeNamelessVictim('b')
|
|
1233
|
+
const dob = stored.dateOfBirth!
|
|
1234
|
+
|
|
1235
|
+
// The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
|
|
1236
|
+
// reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
|
|
1237
|
+
// records carrying this run-unique DOB.
|
|
1238
|
+
await startBooking({ dateOfBirth: dob })
|
|
1239
|
+
|
|
1240
|
+
const withDob = await sdk.api.endusers.getSome({ filter: { dateOfBirth: dob } })
|
|
1241
|
+
withDob.forEach(e => { if (e.id !== victim.id) createdEnduserIds.push(e.id) })
|
|
1242
|
+
|
|
1243
|
+
record('G20', G20_NAME, withDob.length > 1,
|
|
1244
|
+
withDob.length > 1
|
|
1245
|
+
? `inserted a new record (${withDob.length} total with this DOB) instead of matching the victim`
|
|
1246
|
+
: `EXPLOIT: matched the victim's record ${victim.id} from a DOB-only request — a booking session was issued for it`)
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
// ---------------------------------------------------------------------------------------------
|
|
1250
|
+
// G21 — The match sentinel must never be stored as a patient's name.
|
|
1251
|
+
// Guard rail on the fix: the fallback belongs in the filter literal only, never written into
|
|
1252
|
+
// enduserInfo (which is spread into both the enduser insert and the form_responses record).
|
|
1253
|
+
// ---------------------------------------------------------------------------------------------
|
|
1254
|
+
await runScenario('G21', 'match sentinel never leaks into stored fname/lname', async () => {
|
|
1255
|
+
const session = await startFormOn(intake.form.id, { dateOfBirth: `${namelessDob}-sentinel` })
|
|
1256
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1257
|
+
|
|
1258
|
+
const created = await sdk.api.endusers.getOne(session.enduserId)
|
|
1259
|
+
const leaked = (v: any) => v === '__never__' || v === 'never'
|
|
1260
|
+
record('G21', 'created enduser fname/lname are not the sentinel',
|
|
1261
|
+
!leaked(created.fname) && !leaked(created.lname),
|
|
1262
|
+
`fname=${JSON.stringify(created.fname)} lname=${JSON.stringify(created.lname)}`)
|
|
1263
|
+
|
|
1264
|
+
const [response] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
|
|
1265
|
+
record('G21', 'form_responses fname/lname are not the sentinel',
|
|
1266
|
+
!leaked(response?.fname) && !leaked(response?.lname),
|
|
1267
|
+
`fname=${JSON.stringify(response?.fname)} lname=${JSON.stringify(response?.lname)}`)
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
// ---------------------------------------------------------------------------------------------
|
|
1271
|
+
// G22 — The cold-start pattern: a public form started with NO intake info, which just creates an
|
|
1272
|
+
// Enduser with an ID. Intake answers (including the authentication fields email/phone) must still
|
|
1273
|
+
// populate that fresh record — the lock is scoped to "matched a pre-existing patient", not to
|
|
1274
|
+
// "any public session", precisely so this keeps working.
|
|
1275
|
+
// ---------------------------------------------------------------------------------------------
|
|
1276
|
+
const coldStartCase = async (label: string, args: any) => {
|
|
1277
|
+
const group = 'G22'
|
|
1278
|
+
const submittedEmail = emailFor(`cold-${label}`)
|
|
1279
|
+
const submittedPhone = nextPhone()
|
|
1280
|
+
|
|
1281
|
+
const session = await startFormOn(intake.form.id, args)
|
|
1282
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1283
|
+
|
|
1284
|
+
const created = await sdk.api.endusers.getOne(session.enduserId)
|
|
1285
|
+
record(group, `${label}: cold start creates a bare enduser`,
|
|
1286
|
+
!created.email && !created.phone && !!session.enduserId, describe(created))
|
|
1287
|
+
|
|
1288
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1289
|
+
accessCode: session.accessCode,
|
|
1290
|
+
responses: intakeAnswers(intake, submittedEmail, submittedPhone),
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
const { enduser } = await pollEnduser(session.enduserId, e => !!e.email && !!e.phone, AWAITED_WINDOW_MS)
|
|
1294
|
+
record(group, `${label}: intake email (an auth field) still populates the cold-started record`,
|
|
1295
|
+
enduser?.email === submittedEmail, `email=${enduser?.email || '(unset)'} expected=${submittedEmail}`)
|
|
1296
|
+
record(group, `${label}: intake phone (an auth field) still populates the cold-started record`,
|
|
1297
|
+
enduser?.phone === submittedPhone, `phone=${enduser?.phone || '(unset)'} expected=${submittedPhone}`)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
|
|
1301
|
+
await runScenario('G22', 'cold start with skipMatch (real webapp shape)',
|
|
1302
|
+
() => coldStartCase('skipMatch', { skipMatch: true }))
|
|
1303
|
+
// And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
|
|
1304
|
+
// (unstorable) and the name branch needs dateOfBirth === "never" literally.
|
|
1305
|
+
await runScenario('G22', 'cold start without skipMatch',
|
|
1306
|
+
() => coldStartCase('no-skipMatch', {}))
|
|
1307
|
+
|
|
1308
|
+
// ---------------------------------------------------------------------------------------------
|
|
1309
|
+
// G23 — businessId must be required on session_for_public_form.
|
|
1310
|
+
//
|
|
1311
|
+
// addPublicEndpoint overwrites the schema's `required: true` for businessId with
|
|
1312
|
+
// mongoIdStringOptional (routing.ts, and its own "todo: make businessId required"), and TypeScript
|
|
1313
|
+
// cannot catch it because CustomAction declares businessId as a non-optional string.
|
|
1314
|
+
//
|
|
1315
|
+
// A falsy businessId then leaves restrictAccess as the IDENTITY function in
|
|
1316
|
+
// buildAllQueries/buildOrganizationWideQueries (database.ts): the tenant clause is not appended at
|
|
1317
|
+
// all, so every query in the handler runs across ALL tenants. It fails OPEN, which is worse than
|
|
1318
|
+
// matching nothing. login_enduser signs the JWT from enduser.businessId, not from the request, so
|
|
1319
|
+
// the returned token is a fully valid session on whatever record matched.
|
|
1320
|
+
//
|
|
1321
|
+
// session_for_public_appointment_booking already guards this (its own comment says the schema
|
|
1322
|
+
// cannot enforce it); this endpoint did not. Note the sentinel fix in G19/G20 does NOT cover this:
|
|
1323
|
+
// sentinels constrain which record shapes the filter can match, not which tenants it searches.
|
|
1324
|
+
// ---------------------------------------------------------------------------------------------
|
|
1325
|
+
const G23_NAME = 'session_for_public_form requires businessId'
|
|
1326
|
+
await runScenario('G23', G23_NAME, async () => {
|
|
1327
|
+
const victim = await makeVictim('P1', { email: emailFor('lock-bizid'), phone: nextPhone() })
|
|
1328
|
+
|
|
1329
|
+
// (a) THE ISSUE. Omit businessId entirely and name the patient directly via enduserId, which
|
|
1330
|
+
// reaches an untenanted findById — no name/DOB guessing required. Pre-fix this hands back a
|
|
1331
|
+
// working session for that patient; post-fix it must 400 before touching the database.
|
|
1332
|
+
const omitted = await post('/v1/session-for-public-form', {
|
|
1333
|
+
formId: intake.form.id,
|
|
1334
|
+
enduserId: victim.id,
|
|
1335
|
+
})
|
|
1336
|
+
if (omitted.data?.enduserId) createdEnduserIds.push(omitted.data.enduserId)
|
|
1337
|
+
|
|
1338
|
+
const blocked = omitted.status === 400 && omitted.data?.message === 'businessId is required'
|
|
1339
|
+
record('G23', `${G23_NAME} — omitted businessId is rejected`, blocked,
|
|
1340
|
+
blocked
|
|
1341
|
+
? 'HTTP 400 businessId is required'
|
|
1342
|
+
: `EXPLOIT: HTTP ${omitted.status} — got enduserId=${omitted.data?.enduserId} authToken=${!!omitted.data?.authToken} for victim ${victim.id} with NO businessId, i.e. tenant filtering was disabled entirely`)
|
|
1343
|
+
|
|
1344
|
+
// (b) Contrast probe: a well-formed but WRONG businessId must not reach the patient. This is what
|
|
1345
|
+
// proves (a) is caused by the missing tenant clause rather than by the lookup always failing.
|
|
1346
|
+
const wrongBiz = await post('/v1/session-for-public-form', {
|
|
1347
|
+
formId: intake.form.id,
|
|
1348
|
+
enduserId: victim.id,
|
|
1349
|
+
businessId: '000000000000000000000001',
|
|
1350
|
+
})
|
|
1351
|
+
record('G23', `${G23_NAME} — a wrong businessId cannot reach the patient`,
|
|
1352
|
+
wrongBiz.status !== 200 && wrongBiz.data?.enduserId !== victim.id,
|
|
1353
|
+
`HTTP ${wrongBiz.status} ${JSON.stringify(wrongBiz.data?.message ?? wrongBiz.data?.enduserId ?? '')}`)
|
|
1354
|
+
|
|
1355
|
+
// (c) Malformed businessId. The router's own mongoIdStringOptional already rejects this, so this
|
|
1356
|
+
// pins that the guard's second clause does not regress the rejection.
|
|
1357
|
+
const badBiz = await post('/v1/session-for-public-form', {
|
|
1358
|
+
formId: intake.form.id,
|
|
1359
|
+
enduserId: victim.id,
|
|
1360
|
+
businessId: 'not-a-mongo-id',
|
|
1361
|
+
})
|
|
1362
|
+
record('G23', `${G23_NAME} — a malformed businessId is rejected`, badBiz.status === 400,
|
|
1363
|
+
`HTTP ${badBiz.status} ${JSON.stringify(badBiz.data?.message ?? '')}`)
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
// ---------------------------------------------------------------------------------------------
|
|
1367
|
+
// G24 / G25 — The token prepare_form_response mints drops `fromPublicSession`.
|
|
1368
|
+
//
|
|
1369
|
+
// The lock in prepare_form_response keys off session.fromPublicSession, but the token it mints
|
|
1370
|
+
// preserved the session's confinement WITHOUT that flag and handed it back in fullURL (?token=...).
|
|
1371
|
+
// Since prepare_form_response is itself in the 'public-form' session scope, a caller can hop twice:
|
|
1372
|
+
// the first hop's response is locked, the second hop's is not — because by then the session no longer
|
|
1373
|
+
// looks like a public one. Submitting an intakeField: 'email' answer against that second response
|
|
1374
|
+
// then repoints the victim's login channel.
|
|
1375
|
+
//
|
|
1376
|
+
// The same flag also gates PHI on refresh_session (G25): when it is falsy the handler returns
|
|
1377
|
+
// redact_field(enduser, 'hashedPassword') — the whole record — instead of just assignedTo.
|
|
1378
|
+
// ---------------------------------------------------------------------------------------------
|
|
1379
|
+
const chainForVictim = async (tag: string, victim: Enduser) => {
|
|
1380
|
+
const t1 = await startFormOn(intake.form.id, {
|
|
1381
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1382
|
+
})
|
|
1383
|
+
if (t1.enduserId) createdEnduserIds.push(t1.enduserId)
|
|
1384
|
+
if (t1.enduserId !== victim.id) return { matched: false as const, t1, t2: undefined, hop1: undefined }
|
|
1385
|
+
|
|
1386
|
+
const hop1 = await asEnduser(t1.authToken).api.form_responses.prepare_form_response({
|
|
1387
|
+
formId: pivotIntake.form.id, enduserId: victim.id,
|
|
1388
|
+
} as any)
|
|
1389
|
+
return { matched: true as const, t1, hop1, t2: tokenFromURL((hop1 as any)?.fullURL) }
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const G24_NAME = 'chained prepare_form_response session stays locked'
|
|
1393
|
+
await runScenario('G24', G24_NAME, async () => {
|
|
1394
|
+
const victimEmail = emailFor('lock-q1')
|
|
1395
|
+
const victim = await makeVictim('Q1', { email: victimEmail, phone: nextPhone() })
|
|
1396
|
+
const attackerEmail = emailFor('atk-q1')
|
|
1397
|
+
|
|
1398
|
+
const { matched, t2 } = await chainForVictim('Q1', victim as Enduser)
|
|
1399
|
+
if (!matched) {
|
|
1400
|
+
record('G24', G24_NAME, false, `probe invalid — first hop did not match victim ${victim.id}`)
|
|
1401
|
+
return
|
|
1402
|
+
}
|
|
1403
|
+
record('G24', 'chained token is returned in fullURL (probe validity)', !!t2, `token extracted=${!!t2}`)
|
|
1404
|
+
if (!t2) return
|
|
1405
|
+
|
|
1406
|
+
// Second hop, using the token the first hop handed back.
|
|
1407
|
+
const hop2 = await asEnduser(t2).api.form_responses.prepare_form_response({
|
|
1408
|
+
formId: pivotIntake.form.id, enduserId: victim.id,
|
|
1409
|
+
} as any)
|
|
1410
|
+
record('G24', 'second-hop prepare succeeds (probe validity)', !!hop2?.accessCode,
|
|
1411
|
+
`accessCode=${!!hop2?.accessCode}`)
|
|
1412
|
+
if (!hop2?.accessCode) return
|
|
1413
|
+
|
|
1414
|
+
const [hop2Response] = await sdk.api.form_responses.getSome({ filter: { accessCode: hop2.accessCode } })
|
|
1415
|
+
record('G24', `${G24_NAME} — second-hop response carries the lock`,
|
|
1416
|
+
(hop2Response as any)?.authFieldIntakeDisabled === true,
|
|
1417
|
+
`authFieldIntakeDisabled=${JSON.stringify((hop2Response as any)?.authFieldIntakeDisabled)}`)
|
|
1418
|
+
|
|
1419
|
+
// The assertion that actually matters: does the victim's login channel move?
|
|
1420
|
+
await asEnduser(t2).api.form_responses.submit_form_response({
|
|
1421
|
+
accessCode: hop2.accessCode,
|
|
1422
|
+
responses: intakeAnswers(pivotIntake, attackerEmail, nextPhone()),
|
|
1423
|
+
})
|
|
1424
|
+
const { enduser } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
|
|
1425
|
+
record('G24', `${G24_NAME} — victim email unchanged after chained submit`,
|
|
1426
|
+
enduser?.email === victimEmail,
|
|
1427
|
+
enduser?.email === victimEmail
|
|
1428
|
+
? `email still ${victimEmail}`
|
|
1429
|
+
: `EXPLOIT: chained session wrote ${enduser?.email} via an intake answer`)
|
|
1430
|
+
})
|
|
1431
|
+
|
|
1432
|
+
const G25_NAME = 'chained session does not expose the enduser record on refresh'
|
|
1433
|
+
await runScenario('G25', G25_NAME, async () => {
|
|
1434
|
+
const victim = await makeVictim('R1', { email: emailFor('lock-r1'), phone: nextPhone() })
|
|
1435
|
+
const { matched, t1, t2 } = await chainForVictim('R1', victim as Enduser)
|
|
1436
|
+
if (!matched) {
|
|
1437
|
+
record('G25', G25_NAME, false, `probe invalid — first hop did not match victim ${victim.id}`)
|
|
1438
|
+
return
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const PHI_KEYS = ['email', 'phone', 'dateOfBirth', 'fname', 'lname'] as const
|
|
1442
|
+
const leakedIn = (body: any) => PHI_KEYS.filter(k => (body?.enduser ?? {})[k] !== undefined)
|
|
1443
|
+
|
|
1444
|
+
// Control: the ORIGINAL public token must already withhold. If this fails the test proves nothing.
|
|
1445
|
+
const base = await postAuthed('/v1/refresh-enduser-session', {}, t1.authToken)
|
|
1446
|
+
record('G25', 'baseline: original public token withholds the record (control)',
|
|
1447
|
+
base.status === 200 && leakedIn(base.data).length === 0,
|
|
1448
|
+
`HTTP ${base.status} leaked=[${leakedIn(base.data).join(', ')}]`)
|
|
1449
|
+
|
|
1450
|
+
if (!t2) { record('G25', G25_NAME, false, 'probe invalid — no chained token'); return }
|
|
1451
|
+
const chained = await postAuthed('/v1/refresh-enduser-session', {}, t2)
|
|
1452
|
+
const leaked = leakedIn(chained.data)
|
|
1453
|
+
record('G25', G25_NAME, chained.status === 200 && leaked.length === 0,
|
|
1454
|
+
leaked.length === 0
|
|
1455
|
+
? `HTTP ${chained.status}, no PHI fields returned`
|
|
1456
|
+
: `EXPLOIT: refresh under the chained token returned [${leaked.join(', ')}] for victim ${victim.id}`)
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
// ---------------------------------------------------------------------------------------------
|
|
1460
|
+
// G26 — Auto-merge re-points the response after the lock was computed.
|
|
1461
|
+
//
|
|
1462
|
+
// authFieldIntakeDisabled is a snapshot taken at session start (`!!existing`). With skipMatch it is
|
|
1463
|
+
// correctly false, because a fresh record really was inserted. But on submission
|
|
1464
|
+
// resolveAutoMergeBeforeSubmission repoints the response at a PRE-EXISTING patient and persists only
|
|
1465
|
+
// enduserId — so the intake write later reads a stale `false` and lands email/phone on the victim.
|
|
1466
|
+
// The merge matches on { fname, lname, dateOfBirth }, so the attacker never needs to know the
|
|
1467
|
+
// victim's email or phone.
|
|
1468
|
+
//
|
|
1469
|
+
// performAutoMerge copies no demographic fields itself, so the intake write is the only channel —
|
|
1470
|
+
// which is why the custom-field assertion below is essential: it proves the write RAN, so "email
|
|
1471
|
+
// unchanged" cannot pass just because nothing happened.
|
|
1472
|
+
// ---------------------------------------------------------------------------------------------
|
|
1473
|
+
const G26_NAME = 'auto-merge does not let an intake answer repoint the merged patient'
|
|
1474
|
+
await runScenario('G26', G26_NAME, async () => {
|
|
1475
|
+
const victimEmail = emailFor('lock-s1')
|
|
1476
|
+
const victimPhone = nextPhone()
|
|
1477
|
+
const victimDob = '07-08-1988'
|
|
1478
|
+
const victim = await sdk.api.endusers.createOne({
|
|
1479
|
+
fname: `Lock${RUN}`,
|
|
1480
|
+
lname: `Merge${RUN}S1`,
|
|
1481
|
+
dateOfBirth: victimDob,
|
|
1482
|
+
email: victimEmail,
|
|
1483
|
+
phone: victimPhone,
|
|
1484
|
+
} as any)
|
|
1485
|
+
createdEnduserIds.push(victim.id)
|
|
1486
|
+
const attackerEmail = emailFor('atk-s1')
|
|
1487
|
+
|
|
1488
|
+
// skipMatch inserts a fresh record and flags it eligibleForAutoMerge.
|
|
1489
|
+
const session = await startFormOn(autoMergeIntake.form.id, { skipMatch: true })
|
|
1490
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1491
|
+
const sourceId = session.enduserId
|
|
1492
|
+
|
|
1493
|
+
const f = autoMergeIntake
|
|
1494
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1495
|
+
accessCode: session.accessCode,
|
|
1496
|
+
responses: [
|
|
1497
|
+
{ fieldId: f.fname.id, fieldTitle: f.fname.title, answer: { type: 'string' as const, value: victim.fname } },
|
|
1498
|
+
{ fieldId: f.lname.id, fieldTitle: f.lname.title, answer: { type: 'string' as const, value: victim.lname } },
|
|
1499
|
+
{ fieldId: f.dateOfBirth.id, fieldTitle: f.dateOfBirth.title, answer: { type: 'dateString' as const, value: victimDob } },
|
|
1500
|
+
{ fieldId: f.email.id, fieldTitle: f.email.title, answer: { type: 'email' as const, value: attackerEmail } },
|
|
1501
|
+
{ fieldId: f.custom.id, fieldTitle: f.custom.title, answer: { type: 'string' as const, value: 'merged-ok' } },
|
|
1502
|
+
] as any,
|
|
1503
|
+
})
|
|
1504
|
+
|
|
1505
|
+
const { enduser: after } = await pollEnduser(victim.id, e => e.email === attackerEmail, OVERWRITE_WINDOW_MS)
|
|
1506
|
+
|
|
1507
|
+
const merged = (after?.mergedIds ?? []).includes(sourceId)
|
|
1508
|
+
record('G26', 'auto-merge actually resolved to the victim (probe validity)', merged,
|
|
1509
|
+
`mergedIds=${JSON.stringify(after?.mergedIds)} source=${sourceId}`)
|
|
1510
|
+
|
|
1511
|
+
const customLanded = (after as any)?.fields?.[MERGE_CUSTOM_KEY] === 'merged-ok'
|
|
1512
|
+
record('G26', 'non-auth intake fields still reconcile onto the merged patient (anti-vacuity)',
|
|
1513
|
+
customLanded, `fields.${MERGE_CUSTOM_KEY}=${JSON.stringify((after as any)?.fields?.[MERGE_CUSTOM_KEY])}`)
|
|
1514
|
+
|
|
1515
|
+
record('G26', `${G26_NAME} — email`, after?.email === victimEmail,
|
|
1516
|
+
after?.email === victimEmail
|
|
1517
|
+
? `email still ${victimEmail}`
|
|
1518
|
+
: `EXPLOIT: auto-merge intake write set ${after?.email} on the merged-into victim`)
|
|
1519
|
+
record('G26', `${G26_NAME} — phone`, after?.phone === victimPhone,
|
|
1520
|
+
after?.phone === victimPhone ? `phone still ${victimPhone}` : `EXPLOIT: phone became ${after?.phone}`)
|
|
1521
|
+
})
|
|
1522
|
+
|
|
1523
|
+
// ---------------------------------------------------------------------------------------------
|
|
1524
|
+
// G27 — The STAFF merge re-points responses too, and must also apply the lock.
|
|
1525
|
+
//
|
|
1526
|
+
// Same shape as G26 but through endusers.merge rather than autoMergeOnSubmission: the response is
|
|
1527
|
+
// created by a skipMatch public session (so the flag is correctly false — a fresh record really was
|
|
1528
|
+
// inserted), then staff resolve that ephemeral duplicate into a real patient. The merge loop copies
|
|
1529
|
+
// enduserId onto every model that has one, so without the fix the response ends up attached to a
|
|
1530
|
+
// patient who pre-existed it while still carrying a stale `false`.
|
|
1531
|
+
//
|
|
1532
|
+
// Note the source enduser is DELETED by the merge, which kills the original public token — so the
|
|
1533
|
+
// stored flag is the load-bearing assertion here, not a follow-up submit.
|
|
1534
|
+
// ---------------------------------------------------------------------------------------------
|
|
1535
|
+
const G27_NAME = 'staff merge applies the lock when it repoints a response'
|
|
1536
|
+
await runScenario('G27', G27_NAME, async () => {
|
|
1537
|
+
const victimEmail = emailFor('lock-t1')
|
|
1538
|
+
const victim = await makeVictim('T1', { email: victimEmail, phone: nextPhone() })
|
|
1539
|
+
|
|
1540
|
+
const session = await startFormOn(intake.form.id, { skipMatch: true })
|
|
1541
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1542
|
+
|
|
1543
|
+
const [beforeMerge] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
|
|
1544
|
+
// Probe validity: if this were already true the test could not distinguish the fix from the
|
|
1545
|
+
// pre-existing state.
|
|
1546
|
+
record('G27', 'skipMatch response starts unlocked (probe validity)',
|
|
1547
|
+
(beforeMerge as any)?.authFieldIntakeDisabled === false,
|
|
1548
|
+
`authFieldIntakeDisabled=${JSON.stringify((beforeMerge as any)?.authFieldIntakeDisabled)}`)
|
|
1549
|
+
|
|
1550
|
+
await sdk.api.endusers.merge({
|
|
1551
|
+
sourceEnduserId: session.enduserId,
|
|
1552
|
+
destinationEnduserId: victim.id,
|
|
1553
|
+
})
|
|
1554
|
+
|
|
1555
|
+
const [afterMerge] = await sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
|
|
1556
|
+
const repointed = afterMerge?.enduserId === victim.id
|
|
1557
|
+
record('G27', 'merge repointed the response at the pre-existing patient (probe validity)', repointed,
|
|
1558
|
+
`enduserId=${afterMerge?.enduserId} expected=${victim.id}`)
|
|
1559
|
+
|
|
1560
|
+
record('G27', G27_NAME, (afterMerge as any)?.authFieldIntakeDisabled === true,
|
|
1561
|
+
(afterMerge as any)?.authFieldIntakeDisabled === true
|
|
1562
|
+
? 'authFieldIntakeDisabled=true'
|
|
1563
|
+
: `EXPLOIT: response now points at pre-existing patient ${victim.id} with `
|
|
1564
|
+
+ `authFieldIntakeDisabled=${JSON.stringify((afterMerge as any)?.authFieldIntakeDisabled)} — `
|
|
1565
|
+
+ `an intake email/phone answer would repoint their login channel`)
|
|
1566
|
+
|
|
1567
|
+
// The victim must be untouched by the merge itself, so a later failure can only come from an
|
|
1568
|
+
// intake write.
|
|
1569
|
+
const stillVictim = await sdk.api.endusers.getOne(victim.id).catch(() => null)
|
|
1570
|
+
record('G27', 'merge did not itself move the victim email', stillVictim?.email === victimEmail,
|
|
1571
|
+
`email=${stillVictim?.email || '(unset)'} expected=${victimEmail}`)
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
// ---------------------------------------------------------------------------------------------
|
|
1575
|
+
// G28 — SCOPE BOUNDARY for the Stripe intake grace.
|
|
1576
|
+
//
|
|
1577
|
+
// The formsort / form-ingestion webhook re-opens a BLANK email/phone for one hour after the Stripe
|
|
1578
|
+
// integration creates a contact (enduser_is_provisional_stripe_record, exercised by G12/R12-R15 in
|
|
1579
|
+
// formsort_webhook_auth_field_lock.test.ts). That endpoint is API-key authenticated. THIS path is
|
|
1580
|
+
// anonymous, and its match only needs a name + DOB or an email, so the same exemption here would
|
|
1581
|
+
// hand an attacker an OTP login channel on a patient who just paid. It must not apply.
|
|
1582
|
+
//
|
|
1583
|
+
// This gate exists because the tempting refactor — moving the grace down into
|
|
1584
|
+
// save_enduser_updates_for_form_response, the single chokepoint — would silently extend it to every
|
|
1585
|
+
// caller including this one, with no other test noticing.
|
|
1586
|
+
// ---------------------------------------------------------------------------------------------
|
|
1587
|
+
const G28_NAME = 'a fresh Stripe-created patient gets no grace on the public form path'
|
|
1588
|
+
await runScenario('G28', G28_NAME, async () => {
|
|
1589
|
+
const victimEmail = emailFor('lock-stripe')
|
|
1590
|
+
// A real Stripe-created record has no dateOfBirth (Stripe never supplies one), so it is only
|
|
1591
|
+
// reachable by email. makeVictim's DOB is kept purely so this probe can match WITHOUT passing an
|
|
1592
|
+
// email to the endpoint, which isolates the intake answers as the only possible source of a write.
|
|
1593
|
+
const victim = await makeVictim('Stripe', {
|
|
1594
|
+
email: victimEmail,
|
|
1595
|
+
source: 'Stripe',
|
|
1596
|
+
stripeCustomerId: `cus_${RUN}g28`,
|
|
1597
|
+
} as any)
|
|
1598
|
+
const attackerEmail = emailFor('atk-stripe')
|
|
1599
|
+
const attackerPhone = nextPhone()
|
|
1600
|
+
|
|
1601
|
+
const session = await startFormOn(intake.form.id, {
|
|
1602
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1603
|
+
})
|
|
1604
|
+
if (session.enduserId) createdEnduserIds.push(session.enduserId)
|
|
1605
|
+
if (session.enduserId !== victim.id) {
|
|
1606
|
+
record('G28', G28_NAME, false,
|
|
1607
|
+
`probe invalid — endpoint inserted ${session.enduserId} instead of matching victim ${victim.id}`)
|
|
1608
|
+
return
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
await asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1612
|
+
accessCode: session.accessCode,
|
|
1613
|
+
responses: intakeAnswers(intake, attackerEmail, attackerPhone),
|
|
1614
|
+
})
|
|
1615
|
+
|
|
1616
|
+
const { enduser } = await pollEnduser(
|
|
1617
|
+
victim.id,
|
|
1618
|
+
e => e.email === attackerEmail || e.phone === attackerPhone,
|
|
1619
|
+
OVERWRITE_WINDOW_MS,
|
|
1620
|
+
)
|
|
1621
|
+
// phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
|
|
1622
|
+
record('G28', `${G28_NAME} — phone`, !enduser?.phone,
|
|
1623
|
+
enduser?.phone
|
|
1624
|
+
? `EXPLOIT: intake answer wrote phone ${enduser.phone} on a Stripe-created patient (was unset)`
|
|
1625
|
+
: 'phone still (unset)')
|
|
1626
|
+
record('G28', `${G28_NAME} — email`, enduser?.email === victimEmail,
|
|
1627
|
+
enduser?.email === victimEmail
|
|
1628
|
+
? `email still ${victimEmail}`
|
|
1629
|
+
: `EXPLOIT: intake answer wrote ${enduser?.email} (was ${victimEmail})`)
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
// ---------------------------------------------------------------------------------------------
|
|
1633
|
+
// Report
|
|
1634
|
+
// ---------------------------------------------------------------------------------------------
|
|
1635
|
+
const failures = results.filter(r => !r.ok)
|
|
1636
|
+
const skipped = results.filter(r => !!r.skipped)
|
|
1637
|
+
console.log('')
|
|
1638
|
+
console.log(' ┌─ Public endpoint auth-field lock — summary ────────────────────────────')
|
|
1639
|
+
for (const r of results) {
|
|
1640
|
+
const status = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'
|
|
1641
|
+
console.log(` │ ${status} ${r.group.padEnd(3)} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)
|
|
1642
|
+
}
|
|
1643
|
+
console.log(` └─ ${results.length - failures.length}/${results.length} passed, ${skipped.length} skipped, ${failures.length} failed`)
|
|
1644
|
+
console.log('')
|
|
1645
|
+
|
|
1646
|
+
// ---------------------------------------------------------------------------------------------
|
|
1647
|
+
// Cleanup — MUST run before the assert loop (see the note at the top of this file).
|
|
1648
|
+
// ---------------------------------------------------------------------------------------------
|
|
1649
|
+
for (const id of createdEventIds) {
|
|
1650
|
+
await sdk.api.calendar_events.deleteOne(id).catch(() => null)
|
|
1651
|
+
}
|
|
1652
|
+
for (const formId of createdFormIds) {
|
|
1653
|
+
const responses = await sdk.api.form_responses.getSome({ filter: { formId } }).catch(() => [])
|
|
1654
|
+
for (const response of responses) {
|
|
1655
|
+
await sdk.api.form_responses.deleteOne(response.id).catch(() => null)
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
for (const id of createdFormFieldIds) {
|
|
1659
|
+
await sdk.api.form_fields.deleteOne(id).catch(() => null)
|
|
1660
|
+
}
|
|
1661
|
+
for (const id of createdFormIds) {
|
|
1662
|
+
await sdk.api.forms.deleteOne(id).catch(() => null)
|
|
1663
|
+
}
|
|
1664
|
+
const uniqueEnduserIds = createdEnduserIds.filter((id, i) => !!id && createdEnduserIds.indexOf(id) === i)
|
|
1665
|
+
for (const id of uniqueEnduserIds) {
|
|
1666
|
+
await sdk.api.endusers.deleteOne(id).catch(() => null)
|
|
1667
|
+
}
|
|
1668
|
+
for (const id of createdTemplateIds) {
|
|
1669
|
+
await sdk.api.calendar_event_templates.deleteOne(id).catch(() => null)
|
|
1670
|
+
}
|
|
1671
|
+
for (const id of createdUserIds) {
|
|
1672
|
+
await sdk.api.users.deleteOne(id).catch(() => null)
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// ---------------------------------------------------------------------------------------------
|
|
1676
|
+
// Assert — single pass, last, so the full table above is always printed first.
|
|
1677
|
+
// ---------------------------------------------------------------------------------------------
|
|
1678
|
+
for (const r of results) {
|
|
1679
|
+
assert(
|
|
1680
|
+
r.ok,
|
|
1681
|
+
`❌ [${r.group}] ${r.name} — ${r.detail}`,
|
|
1682
|
+
`[${r.group}] ${r.name}${r.skipped ? ' (SKIPPED)' : ''}`,
|
|
1683
|
+
)
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Allow running this test file independently
|
|
1688
|
+
if (require.main === module) {
|
|
1689
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
1690
|
+
const sdk = new Session({ host })
|
|
1691
|
+
const sdkNonAdmin = new Session({ host })
|
|
1692
|
+
|
|
1693
|
+
const runTests = async () => {
|
|
1694
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
1695
|
+
await public_endpoint_auth_field_lock_tests({ sdk, sdkNonAdmin })
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
runTests()
|
|
1699
|
+
.then(() => {
|
|
1700
|
+
console.log("✅ Public endpoint auth field lock test suite completed successfully")
|
|
1701
|
+
process.exit(0)
|
|
1702
|
+
})
|
|
1703
|
+
.catch((error) => {
|
|
1704
|
+
console.error("❌ Public endpoint auth field lock test suite failed:", error)
|
|
1705
|
+
process.exit(1)
|
|
1706
|
+
})
|
|
1707
|
+
}
|