@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,2323 @@
|
|
|
1
|
+
var __assign = (this && this.__assign) || function () {
|
|
2
|
+
__assign = Object.assign || function(t) {
|
|
3
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
4
|
+
s = arguments[i];
|
|
5
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
6
|
+
t[p] = s[p];
|
|
7
|
+
}
|
|
8
|
+
return t;
|
|
9
|
+
};
|
|
10
|
+
return __assign.apply(this, arguments);
|
|
11
|
+
};
|
|
12
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
13
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
14
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
15
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
16
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
17
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
18
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
22
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
|
|
23
|
+
return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
24
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
25
|
+
function step(op) {
|
|
26
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
27
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
28
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
29
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
30
|
+
switch (op[0]) {
|
|
31
|
+
case 0: case 1: t = op; break;
|
|
32
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
33
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
34
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
35
|
+
default:
|
|
36
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
37
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
38
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
39
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
40
|
+
if (t[2]) _.ops.pop();
|
|
41
|
+
_.trys.pop(); continue;
|
|
42
|
+
}
|
|
43
|
+
op = body.call(thisArg, _);
|
|
44
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
45
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
require('source-map-support').install();
|
|
49
|
+
import axios from "axios";
|
|
50
|
+
import { Session, EnduserSession } from "../../../sdk";
|
|
51
|
+
import { assert, log_header, } from "@tellescope/testing";
|
|
52
|
+
import { setup_tests, authenticate_enduser_via_token } from "../../setup";
|
|
53
|
+
var host = process.env.API_URL || 'http://localhost:8080';
|
|
54
|
+
/**
|
|
55
|
+
* Regression test for: email / phone are locked against writes from public session-start endpoints.
|
|
56
|
+
*
|
|
57
|
+
* Two public, unauthenticated endpoints match an existing patient on a loose $or
|
|
58
|
+
* ({ email } OR { fname, lname, dateOfBirth }) and then update the matched record:
|
|
59
|
+
*
|
|
60
|
+
* A POST /v1/session-for-public-form
|
|
61
|
+
* B POST /v1/session-for-public-appointment-booking
|
|
62
|
+
*
|
|
63
|
+
* Both used to overwrite `email`, `phone` and `state` on the matched patient. `email` and `phone`
|
|
64
|
+
* are authentication channels, so that overwrite chains to account takeover:
|
|
65
|
+
*
|
|
66
|
+
* - email-less patients are common and cheap to create (sms_messages.leave_message is an
|
|
67
|
+
* unauthenticated enduser-creation primitive that writes only phone/fname/lname)
|
|
68
|
+
* - attacker submits a public form matching on fname + lname + dateOfBirth with their own email
|
|
69
|
+
* - POST /v1/request-enduser-password-reset resolves purely by findOne({ email }) -> mints a
|
|
70
|
+
* passwordReset token and mails the reset link to the attacker
|
|
71
|
+
* - reset_password sets hashedPassword; the attacker logs into the portal as the patient
|
|
72
|
+
*
|
|
73
|
+
* phone is equally load-bearing: begin_login_flow accepts email OR phone, and send_otp delivers
|
|
74
|
+
* login codes to enduser.phone. A "fill only if blank" fix does NOT close this — the email-less
|
|
75
|
+
* patient is precisely the common case, which is why the UNSET shapes below are the load-bearing
|
|
76
|
+
* rows of G1/G2.
|
|
77
|
+
*
|
|
78
|
+
* The rule under test: `email` and `phone` may be written only on the INSERT path. On a matched
|
|
79
|
+
* existing patient they are never written — not even when the stored value is blank.
|
|
80
|
+
*
|
|
81
|
+
* `state` deliberately still overwrites on match (G6 pins the status quo): it is not an auth
|
|
82
|
+
* channel, and moving it behind "if unset" would break a relocated patient booking with a
|
|
83
|
+
* provider licensed in their new state.
|
|
84
|
+
*
|
|
85
|
+
* Scenario groups (pre-fix expectation in parens):
|
|
86
|
+
* G1 (RED) A — overwrite blocked on match, 4 victim shapes (both set / email unset / phone unset / both unset)
|
|
87
|
+
* G2 (RED) B — same 4 shapes
|
|
88
|
+
* G3 (RED) A — overwrite blocked when the victim is targeted directly via the enduserId param
|
|
89
|
+
* G4 (RED) takeover chain: email-less victim -> overwrite via A -> password-reset resolvability
|
|
90
|
+
* G5 (GREEN) blank fname/lname/dateOfBirth/gender still filled; blank phone still NOT filled
|
|
91
|
+
* G6 (GREEN) state still overwrites on match (status-quo pin)
|
|
92
|
+
* G7 (GREEN) brand-new visitor still created WITH submitted email + phone
|
|
93
|
+
* G8 (GREEN) form submission completes end-to-end under the returned token
|
|
94
|
+
* G9 (GREEN) appointment availability / booking works under the returned session
|
|
95
|
+
* G10 (GREEN) skipMatch still inserts, and skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge
|
|
96
|
+
*
|
|
97
|
+
* SECOND WRITE PATH (G11-G18). Locking the endpoints is not sufficient on its own. `email` and
|
|
98
|
+
* `phone` are also UI-selectable form intakeField mappings, and save_enduser_updates_for_form_response
|
|
99
|
+
* (api/modules/forms.ts) copies those answers onto the enduser named by the form response — with no
|
|
100
|
+
* session argument and no only-if-blank guard. Two of its callers sit in the 'public-form' session scope
|
|
101
|
+
* (submit_form_response, and save_field_response for fields with options.saveIntakeOnPartial), so the
|
|
102
|
+
* whole chain above is reachable with the token the hardened endpoint itself mints: match the victim,
|
|
103
|
+
* get a session scoped to their id, then answer the form's Email question with the attacker address.
|
|
104
|
+
*
|
|
105
|
+
* The lock is scoped to "a public session that matched a PRE-EXISTING patient", not to every public
|
|
106
|
+
* session, because intakeEmailHidden / intakePhone: 'hidden' are supported configurations in which
|
|
107
|
+
* the intake question is the only source of a brand-new patient's contact info (G16).
|
|
108
|
+
*
|
|
109
|
+
* G11 (RED) intake answers do not overwrite a matched patient (submit path), 4 victim shapes
|
|
110
|
+
* G12 (RED) same via save_field_response + saveIntakeOnPartial, without ever submitting
|
|
111
|
+
* G13 (RED) full takeover chain driven through an intake answer
|
|
112
|
+
* G14 (RED) pivot: public session -> prepare_form_response for a NON-public form -> submit
|
|
113
|
+
* G15 (RED) save_field_response rejects another patient's formResponseId (accessCode path stays open)
|
|
114
|
+
* G16 (GREEN) brand-new visitor still gets email/phone from intake answers
|
|
115
|
+
* G17 (GREEN) authenticated (non-public) portal session can still self-update via intake
|
|
116
|
+
* G18 (GREEN) staff submitting on a patient's behalf can still set email/phone
|
|
117
|
+
*
|
|
118
|
+
* NULL-MATCH / COLD START (G19-G22). In both handlers' $or, `email` and `dateOfBirth` get `|| "never"`
|
|
119
|
+
* sentinels but fname/lname are interpolated RAW. `ignoreUndefined` is not set on the Mongo client and
|
|
120
|
+
* findOne does not strip undefined keys, so an absent fname serializes to `{fname: null}` — which
|
|
121
|
+
* matches every document where the field is null OR MISSING. A request supplying ONLY dateOfBirth can
|
|
122
|
+
* therefore match another patient and receive a session scoped to THEIR record. The cold-start pattern
|
|
123
|
+
* (a public form started with no intake info, which just creates an Enduser with an ID) is what
|
|
124
|
+
* manufactures those nameless endusers.
|
|
125
|
+
*
|
|
126
|
+
* G19 (RED) DOB-only request does not hijack a nameless patient — form endpoint
|
|
127
|
+
* G20 (RED) same — booking endpoint (no skipMatch, no phone guard, so reached unconditionally)
|
|
128
|
+
* G21 (GREEN) the match sentinel never leaks into a stored fname/lname
|
|
129
|
+
* G22 (GREEN) cold start still lets intake answers populate email/phone on the fresh record
|
|
130
|
+
*
|
|
131
|
+
* TENANT SCOPING (G23). addPublicEndpoint overwrites the schema's `required: true` for businessId with
|
|
132
|
+
* mongoIdStringOptional, and a falsy businessId leaves restrictAccess as the IDENTITY function in the
|
|
133
|
+
* query builders — the tenant clause is never appended, so the handler queries every tenant. It fails
|
|
134
|
+
* OPEN. session_for_public_appointment_booking already guards this; session_for_public_form did not.
|
|
135
|
+
*
|
|
136
|
+
* G23 (RED) session_for_public_form rejects a missing or malformed businessId
|
|
137
|
+
*
|
|
138
|
+
* LOCK BYPASSES (G24-G26). The lock is defeated two ways, each needing more than one hop — which is
|
|
139
|
+
* why the single-hop groups above pass while the chain is still open:
|
|
140
|
+
* - prepare_form_response mints a token that preserves allowedPaths but DROPS fromPublicSession, so
|
|
141
|
+
* a second hop through the same endpoint produces an unlocked response. That flag also gates PHI
|
|
142
|
+
* on refresh_session, so the chained token additionally reads the victim's whole record.
|
|
143
|
+
* - the lock is a snapshot taken at session start; a later repoint of the response at a pre-existing
|
|
144
|
+
* patient leaves a stale `false` behind unless it also writes the flag. Two callers repoint:
|
|
145
|
+
* autoMergeOnSubmission (G26) and the staff endusers.merge handler (G27).
|
|
146
|
+
*
|
|
147
|
+
* G24 (RED) chained prepare_form_response session stays locked
|
|
148
|
+
* G25 (RED) chained session does not expose the enduser record on refresh
|
|
149
|
+
* G26 (RED) auto-merge does not let an intake answer repoint the merged patient
|
|
150
|
+
* G27 (RED) the staff endusers.merge repoint applies the lock too
|
|
151
|
+
*
|
|
152
|
+
* SCOPE BOUNDARY (G28). The formsort / form-ingestion webhook re-opens a blank email/phone for one
|
|
153
|
+
* hour after the Stripe integration creates a contact, because that endpoint is API-key
|
|
154
|
+
* authenticated. This anonymous path gets no such exemption.
|
|
155
|
+
*
|
|
156
|
+
* G28 a fresh Stripe-created patient gets no grace here
|
|
157
|
+
*
|
|
158
|
+
* STRUCTURE: record -> report -> cleanup -> assert.
|
|
159
|
+
* EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
|
|
160
|
+
* first failure. Two consequences shape this file:
|
|
161
|
+
* 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
|
|
162
|
+
* single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
|
|
163
|
+
* instead of dying on the first probe.
|
|
164
|
+
* 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
|
|
165
|
+
* loop; otherwise a by-design-failing run would leak every fixture into the next run.
|
|
166
|
+
*/
|
|
167
|
+
var post = function (path, body) { return __awaiter(void 0, void 0, void 0, function () {
|
|
168
|
+
var res, err_1;
|
|
169
|
+
var _a, _b;
|
|
170
|
+
return __generator(this, function (_c) {
|
|
171
|
+
switch (_c.label) {
|
|
172
|
+
case 0:
|
|
173
|
+
_c.trys.push([0, 2, , 3]);
|
|
174
|
+
return [4 /*yield*/, axios.post("".concat(host).concat(path), body, { validateStatus: function () { return true; } })];
|
|
175
|
+
case 1:
|
|
176
|
+
res = _c.sent();
|
|
177
|
+
return [2 /*return*/, { status: res.status, data: res.data }];
|
|
178
|
+
case 2:
|
|
179
|
+
err_1 = _c.sent();
|
|
180
|
+
return [2 /*return*/, { status: (_a = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _b === void 0 ? void 0 : _b.data }];
|
|
181
|
+
case 3: return [2 /*return*/];
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}); };
|
|
185
|
+
// Endpoint A's update is fire-and-forget (.catch(console.error), not awaited); B's is awaited.
|
|
186
|
+
// Reading the victim immediately after calling A would frequently show the un-overwritten value on
|
|
187
|
+
// a VULNERABLE server -> false pass. So poll for the attack landing: fails fast against vulnerable
|
|
188
|
+
// code, burns the full window against fixed code.
|
|
189
|
+
var OVERWRITE_WINDOW_MS = 3000;
|
|
190
|
+
var AWAITED_WINDOW_MS = 1200;
|
|
191
|
+
var POLL_INTERVAL_MS = 150;
|
|
192
|
+
var sleep = function (ms) { return new Promise(function (resolve) { return setTimeout(resolve, ms); }); };
|
|
193
|
+
export var public_endpoint_auth_field_lock_tests = function (_a) {
|
|
194
|
+
var sdk = _a.sdk, sdkNonAdmin = _a.sdkNonAdmin;
|
|
195
|
+
return __awaiter(void 0, void 0, void 0, function () {
|
|
196
|
+
var businessId, RUN, results, record, skip, runScenario, phoneOffset, phoneSeed, nextPhone, emailFor, createdEnduserIds, createdFormIds, createdFormFieldIds, createdUserIds, createdEventIds, createdTemplateIds, organization, MATCH_EMAIL_AND_NAMES, form, plainField, autoMergeForm, autoMergeField, makeIntakeForm, intake, partialIntake, pivotIntake, MERGE_CUSTOM_KEY, autoMergeIntake, template, hostUser, publicSDK, startForm, startBooking, startFormOn, asEnduser, postAuthed, tokenFromURL, patchAuthed, intakeAnswers, pollEnduser, describe, makeVictim, VICTIM_SHAPES, _loop_1, _i, VICTIM_SHAPES_1, shape, _loop_2, _b, VICTIM_SHAPES_2, shape, formSessions, bookingTokens, _loop_3, _c, VICTIM_SHAPES_3, shape, G12_NAME, G13_NAME, G14_NAME, G15_NAME, namelessDob, makeNamelessVictim, G19_NAME, G20_NAME, coldStartCase, G23_NAME, chainForVictim, G24_NAME, G25_NAME, G26_NAME, G27_NAME, G28_NAME, failures, skipped, _d, results_1, r, status_1, _e, createdEventIds_1, id, _f, createdFormIds_1, formId, responses, _g, responses_1, response, _h, createdFormFieldIds_1, id, _j, createdFormIds_2, id, uniqueEnduserIds, _k, uniqueEnduserIds_1, id, _l, createdTemplateIds_1, id, _m, createdUserIds_1, id, _o, results_2, r;
|
|
197
|
+
var _p, _q;
|
|
198
|
+
return __generator(this, function (_r) {
|
|
199
|
+
switch (_r.label) {
|
|
200
|
+
case 0:
|
|
201
|
+
log_header("Public Endpoint Auth Field Lock (email / phone)");
|
|
202
|
+
businessId = sdk.userInfo.businessId;
|
|
203
|
+
RUN = Date.now().toString(36);
|
|
204
|
+
results = [];
|
|
205
|
+
record = function (group, name, ok, detail) {
|
|
206
|
+
if (detail === void 0) { detail = ''; }
|
|
207
|
+
results.push({ group: group, name: name, ok: ok, detail: detail });
|
|
208
|
+
console.log(" ".concat(ok ? '✓' : '✗', " [").concat(group, "] ").concat(name).concat(detail ? " \u2014 ".concat(detail) : ''));
|
|
209
|
+
};
|
|
210
|
+
skip = function (group, name, detail) {
|
|
211
|
+
results.push({ group: group, name: name, ok: true, skipped: true, detail: detail });
|
|
212
|
+
console.log(" \u26A0 SKIP [".concat(group, "] ").concat(name, " \u2014 ").concat(detail));
|
|
213
|
+
};
|
|
214
|
+
runScenario = function (group, name, fn) { return __awaiter(void 0, void 0, void 0, function () {
|
|
215
|
+
var err_2, message;
|
|
216
|
+
var _a, _b;
|
|
217
|
+
return __generator(this, function (_c) {
|
|
218
|
+
switch (_c.label) {
|
|
219
|
+
case 0:
|
|
220
|
+
_c.trys.push([0, 2, , 3]);
|
|
221
|
+
return [4 /*yield*/, fn()];
|
|
222
|
+
case 1:
|
|
223
|
+
_c.sent();
|
|
224
|
+
return [3 /*break*/, 3];
|
|
225
|
+
case 2:
|
|
226
|
+
err_2 = _c.sent();
|
|
227
|
+
message = (_b = (_a = err_2 === null || err_2 === void 0 ? void 0 : err_2.message) !== null && _a !== void 0 ? _a : err_2 === null || err_2 === void 0 ? void 0 : err_2.error) !== null && _b !== void 0 ? _b : JSON.stringify(err_2 !== null && err_2 !== void 0 ? err_2 : {}).slice(0, 300);
|
|
228
|
+
record(group, name, false, "threw: ".concat(message));
|
|
229
|
+
return [3 /*break*/, 3];
|
|
230
|
+
case 3: return [2 /*return*/];
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}); };
|
|
234
|
+
phoneOffset = 0;
|
|
235
|
+
phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0');
|
|
236
|
+
nextPhone = function () { return "+1555555".concat(String((phoneSeed + (phoneOffset += 1)) % 10000).padStart(4, '0')); };
|
|
237
|
+
emailFor = function (label) { return "".concat(label, "-").concat(RUN, "@tellescope.com").toLowerCase(); };
|
|
238
|
+
createdEnduserIds = [];
|
|
239
|
+
createdFormIds = [];
|
|
240
|
+
createdFormFieldIds = [];
|
|
241
|
+
createdUserIds = [];
|
|
242
|
+
createdEventIds = [];
|
|
243
|
+
createdTemplateIds = [];
|
|
244
|
+
return [4 /*yield*/, sdk.api.organizations.getOne(businessId).catch(function () { return null; })];
|
|
245
|
+
case 1:
|
|
246
|
+
organization = _r.sent();
|
|
247
|
+
MATCH_EMAIL_AND_NAMES = !!((_q = (_p = organization === null || organization === void 0 ? void 0 : organization.settings) === null || _p === void 0 ? void 0 : _p.endusers) === null || _q === void 0 ? void 0 : _q.matchEmailAndNames);
|
|
248
|
+
return [4 /*yield*/, sdk.api.forms.createOne({ title: "auth field lock ".concat(RUN), allowPublicURL: true })];
|
|
249
|
+
case 2:
|
|
250
|
+
form = _r.sent();
|
|
251
|
+
createdFormIds.push(form.id);
|
|
252
|
+
return [4 /*yield*/, sdk.api.form_fields.createOne({
|
|
253
|
+
formId: form.id,
|
|
254
|
+
title: 'Anything',
|
|
255
|
+
type: 'string',
|
|
256
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
257
|
+
})];
|
|
258
|
+
case 3:
|
|
259
|
+
plainField = _r.sent();
|
|
260
|
+
createdFormFieldIds.push(plainField.id);
|
|
261
|
+
return [4 /*yield*/, sdk.api.forms.createOne({
|
|
262
|
+
title: "auth field lock automerge ".concat(RUN),
|
|
263
|
+
allowPublicURL: true,
|
|
264
|
+
autoMergeOnSubmission: true,
|
|
265
|
+
})];
|
|
266
|
+
case 4:
|
|
267
|
+
autoMergeForm = _r.sent();
|
|
268
|
+
createdFormIds.push(autoMergeForm.id);
|
|
269
|
+
return [4 /*yield*/, sdk.api.form_fields.createOne({
|
|
270
|
+
formId: autoMergeForm.id,
|
|
271
|
+
title: 'Anything',
|
|
272
|
+
type: 'string',
|
|
273
|
+
previousFields: [{ type: 'root', info: {} }],
|
|
274
|
+
})];
|
|
275
|
+
case 5:
|
|
276
|
+
autoMergeField = _r.sent();
|
|
277
|
+
createdFormFieldIds.push(autoMergeField.id);
|
|
278
|
+
makeIntakeForm = function (label, opts) {
|
|
279
|
+
if (opts === void 0) { opts = {}; }
|
|
280
|
+
return __awaiter(void 0, void 0, void 0, function () {
|
|
281
|
+
var intakeForm, fieldOptions, emailField, phoneField;
|
|
282
|
+
return __generator(this, function (_a) {
|
|
283
|
+
switch (_a.label) {
|
|
284
|
+
case 0: return [4 /*yield*/, sdk.api.forms.createOne(__assign({ title: "auth field lock ".concat(label, " ").concat(RUN), intakePhone: 'hidden' }, (opts.public === false ? {} : { allowPublicURL: true })))];
|
|
285
|
+
case 1:
|
|
286
|
+
intakeForm = _a.sent();
|
|
287
|
+
createdFormIds.push(intakeForm.id);
|
|
288
|
+
fieldOptions = opts.saveIntakeOnPartial ? { options: { saveIntakeOnPartial: true } } : {};
|
|
289
|
+
return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Email', type: 'email', intakeField: 'email', previousFields: [{ type: 'root', info: {} }] }, fieldOptions))];
|
|
290
|
+
case 2:
|
|
291
|
+
emailField = _a.sent();
|
|
292
|
+
createdFormFieldIds.push(emailField.id);
|
|
293
|
+
return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Phone Number', type: 'phone', intakeField: 'phone', previousFields: [{ type: 'after', info: { fieldId: emailField.id } }] }, fieldOptions))];
|
|
294
|
+
case 3:
|
|
295
|
+
phoneField = _a.sent();
|
|
296
|
+
createdFormFieldIds.push(phoneField.id);
|
|
297
|
+
return [2 /*return*/, { form: intakeForm, emailField: emailField, phoneField: phoneField }];
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
return [4 /*yield*/, makeIntakeForm('intake')];
|
|
303
|
+
case 6:
|
|
304
|
+
intake = _r.sent();
|
|
305
|
+
return [4 /*yield*/, makeIntakeForm('partial', { saveIntakeOnPartial: true })
|
|
306
|
+
// Deliberately NOT allowPublicURL. prepare_form_response does not require it, so a public session
|
|
307
|
+
// can pivot from any public form to any form in the org carrying an email/phone intake mapping.
|
|
308
|
+
];
|
|
309
|
+
case 7:
|
|
310
|
+
partialIntake = _r.sent();
|
|
311
|
+
return [4 /*yield*/, makeIntakeForm('pivot', { public: false })
|
|
312
|
+
// Auto-merge fixture for G26. Needs real intakeField mappings, because resolveAutoMergeBeforeSubmission
|
|
313
|
+
// derives its match values from the submitted answers via those mappings — and the custom field is the
|
|
314
|
+
// anti-vacuity control (it proves the intake write ran at all).
|
|
315
|
+
];
|
|
316
|
+
case 8:
|
|
317
|
+
pivotIntake = _r.sent();
|
|
318
|
+
MERGE_CUSTOM_KEY = "aflMerge".concat(RUN);
|
|
319
|
+
return [4 /*yield*/, (function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
320
|
+
var form, spec, made, _i, spec_1, s, field, fname, lname, dateOfBirth, email, custom;
|
|
321
|
+
return __generator(this, function (_a) {
|
|
322
|
+
switch (_a.label) {
|
|
323
|
+
case 0: return [4 /*yield*/, sdk.api.forms.createOne({
|
|
324
|
+
title: "auth field lock automerge intake ".concat(RUN),
|
|
325
|
+
allowPublicURL: true,
|
|
326
|
+
autoMergeOnSubmission: true,
|
|
327
|
+
intakePhone: 'hidden',
|
|
328
|
+
})];
|
|
329
|
+
case 1:
|
|
330
|
+
form = _a.sent();
|
|
331
|
+
createdFormIds.push(form.id);
|
|
332
|
+
spec = [
|
|
333
|
+
{ title: 'First Name', type: 'string', intakeField: 'fname' },
|
|
334
|
+
{ title: 'Last Name', type: 'string', intakeField: 'lname' },
|
|
335
|
+
{ title: 'Date of Birth', type: 'dateString', intakeField: 'dateOfBirth' },
|
|
336
|
+
{ title: 'Email', type: 'email', intakeField: 'email' },
|
|
337
|
+
{ title: 'Custom', type: 'string', intakeField: MERGE_CUSTOM_KEY },
|
|
338
|
+
];
|
|
339
|
+
made = [];
|
|
340
|
+
_i = 0, spec_1 = spec;
|
|
341
|
+
_a.label = 2;
|
|
342
|
+
case 2:
|
|
343
|
+
if (!(_i < spec_1.length)) return [3 /*break*/, 5];
|
|
344
|
+
s = spec_1[_i];
|
|
345
|
+
return [4 /*yield*/, sdk.api.form_fields.createOne({
|
|
346
|
+
formId: form.id,
|
|
347
|
+
title: s.title,
|
|
348
|
+
type: s.type,
|
|
349
|
+
intakeField: s.intakeField,
|
|
350
|
+
previousFields: made.length
|
|
351
|
+
? [{ type: 'after', info: { fieldId: made[made.length - 1].id } }]
|
|
352
|
+
: [{ type: 'root', info: {} }],
|
|
353
|
+
})];
|
|
354
|
+
case 3:
|
|
355
|
+
field = _a.sent();
|
|
356
|
+
createdFormFieldIds.push(field.id);
|
|
357
|
+
made.push(field);
|
|
358
|
+
_a.label = 4;
|
|
359
|
+
case 4:
|
|
360
|
+
_i++;
|
|
361
|
+
return [3 /*break*/, 2];
|
|
362
|
+
case 5:
|
|
363
|
+
fname = made[0], lname = made[1], dateOfBirth = made[2], email = made[3], custom = made[4];
|
|
364
|
+
return [2 /*return*/, { form: form, fname: fname, lname: lname, dateOfBirth: dateOfBirth, email: email, custom: custom }];
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}); })()];
|
|
368
|
+
case 9:
|
|
369
|
+
autoMergeIntake = _r.sent();
|
|
370
|
+
return [4 /*yield*/, sdk.api.calendar_event_templates.createOne({
|
|
371
|
+
title: "auth field lock ".concat(RUN),
|
|
372
|
+
durationInMinutes: 60,
|
|
373
|
+
confirmationEmailDisabled: true,
|
|
374
|
+
confirmationSMSDisabled: true,
|
|
375
|
+
reminders: [],
|
|
376
|
+
})];
|
|
377
|
+
case 10:
|
|
378
|
+
template = _r.sent();
|
|
379
|
+
createdTemplateIds.push(template.id);
|
|
380
|
+
return [4 /*yield*/, sdk.api.users.createOne({
|
|
381
|
+
email: emailFor('lock-host'),
|
|
382
|
+
verifiedEmail: true,
|
|
383
|
+
fname: 'Lock',
|
|
384
|
+
lname: "Host".concat(RUN),
|
|
385
|
+
})];
|
|
386
|
+
case 11:
|
|
387
|
+
hostUser = _r.sent();
|
|
388
|
+
createdUserIds.push(hostUser.id);
|
|
389
|
+
return [4 /*yield*/, sdk.api.users.updateOne(hostUser.id, {
|
|
390
|
+
weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(function (dayOfWeekStartingSundayIndexedByZero) { return ({
|
|
391
|
+
dayOfWeekStartingSundayIndexedByZero: dayOfWeekStartingSundayIndexedByZero,
|
|
392
|
+
startTimeInMinutes: 9 * 60,
|
|
393
|
+
endTimeInMinutes: 17 * 60,
|
|
394
|
+
intervalInMinutes: 60,
|
|
395
|
+
}); }),
|
|
396
|
+
calendarEventLimits: [],
|
|
397
|
+
}, { replaceObjectFields: true })];
|
|
398
|
+
case 12:
|
|
399
|
+
_r.sent();
|
|
400
|
+
return [4 /*yield*/, sleep(250)
|
|
401
|
+
// ---------------------------------------------------------------------------------------------
|
|
402
|
+
// Helpers
|
|
403
|
+
// ---------------------------------------------------------------------------------------------
|
|
404
|
+
]; // let form.numFields settle before the public endpoints validate it
|
|
405
|
+
case 13:
|
|
406
|
+
_r.sent(); // let form.numFields settle before the public endpoints validate it
|
|
407
|
+
publicSDK = function () { return new EnduserSession({ host: host, businessId: businessId }); };
|
|
408
|
+
startForm = function (args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: form.id, businessId: businessId }, args)); };
|
|
409
|
+
startBooking = function (args) { return publicSDK().api.calendar_events.session_for_public_appointment_booking(__assign({ calendarEventTemplateId: template.id, businessId: businessId }, args)); };
|
|
410
|
+
startFormOn = function (formId, args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: formId, businessId: businessId }, args)); };
|
|
411
|
+
asEnduser = function (authToken) { return new EnduserSession({ host: host, businessId: businessId, authToken: authToken }); };
|
|
412
|
+
postAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
|
|
413
|
+
var res, err_3;
|
|
414
|
+
var _a, _b;
|
|
415
|
+
return __generator(this, function (_c) {
|
|
416
|
+
switch (_c.label) {
|
|
417
|
+
case 0:
|
|
418
|
+
_c.trys.push([0, 2, , 3]);
|
|
419
|
+
return [4 /*yield*/, axios.post("".concat(host).concat(path), body, {
|
|
420
|
+
headers: { Authorization: "Bearer ".concat(authToken) },
|
|
421
|
+
validateStatus: function () { return true; },
|
|
422
|
+
})];
|
|
423
|
+
case 1:
|
|
424
|
+
res = _c.sent();
|
|
425
|
+
return [2 /*return*/, { status: res.status, data: res.data }];
|
|
426
|
+
case 2:
|
|
427
|
+
err_3 = _c.sent();
|
|
428
|
+
return [2 /*return*/, { status: (_a = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _b === void 0 ? void 0 : _b.data }];
|
|
429
|
+
case 3: return [2 /*return*/];
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}); };
|
|
433
|
+
tokenFromURL = function (fullURL) { var _a; return (_a = /[?&]token=([^&]+)/.exec(fullURL !== null && fullURL !== void 0 ? fullURL : '')) === null || _a === void 0 ? void 0 : _a[1]; };
|
|
434
|
+
patchAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
|
|
435
|
+
var res, err_4;
|
|
436
|
+
var _a, _b;
|
|
437
|
+
return __generator(this, function (_c) {
|
|
438
|
+
switch (_c.label) {
|
|
439
|
+
case 0:
|
|
440
|
+
_c.trys.push([0, 2, , 3]);
|
|
441
|
+
return [4 /*yield*/, axios.patch("".concat(host).concat(path), body, {
|
|
442
|
+
headers: { Authorization: "Bearer ".concat(authToken) },
|
|
443
|
+
validateStatus: function () { return true; },
|
|
444
|
+
})];
|
|
445
|
+
case 1:
|
|
446
|
+
res = _c.sent();
|
|
447
|
+
return [2 /*return*/, { status: res.status, data: res.data }];
|
|
448
|
+
case 2:
|
|
449
|
+
err_4 = _c.sent();
|
|
450
|
+
return [2 /*return*/, { status: (_a = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _b === void 0 ? void 0 : _b.data }];
|
|
451
|
+
case 3: return [2 /*return*/];
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
}); };
|
|
455
|
+
intakeAnswers = function (f, email, phone) { return ([
|
|
456
|
+
{ fieldId: f.emailField.id, fieldTitle: f.emailField.title, answer: { type: 'email', value: email } },
|
|
457
|
+
{ fieldId: f.phoneField.id, fieldTitle: f.phoneField.title, answer: { type: 'phone', value: phone } },
|
|
458
|
+
]); };
|
|
459
|
+
pollEnduser = function (id, landed, windowMs) { return __awaiter(void 0, void 0, void 0, function () {
|
|
460
|
+
var deadline, latest;
|
|
461
|
+
return __generator(this, function (_a) {
|
|
462
|
+
switch (_a.label) {
|
|
463
|
+
case 0:
|
|
464
|
+
deadline = Date.now() + windowMs;
|
|
465
|
+
latest = null;
|
|
466
|
+
_a.label = 1;
|
|
467
|
+
case 1:
|
|
468
|
+
if (!true) return [3 /*break*/, 4];
|
|
469
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(id).catch(function () { return null; })];
|
|
470
|
+
case 2:
|
|
471
|
+
latest = _a.sent();
|
|
472
|
+
if (latest && landed(latest))
|
|
473
|
+
return [2 /*return*/, { landed: true, enduser: latest }];
|
|
474
|
+
if (Date.now() >= deadline)
|
|
475
|
+
return [2 /*return*/, { landed: false, enduser: latest }];
|
|
476
|
+
return [4 /*yield*/, sleep(POLL_INTERVAL_MS)];
|
|
477
|
+
case 3:
|
|
478
|
+
_a.sent();
|
|
479
|
+
return [3 /*break*/, 1];
|
|
480
|
+
case 4: return [2 /*return*/];
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
}); };
|
|
484
|
+
describe = function (e) { return "email=".concat((e === null || e === void 0 ? void 0 : e.email) || '(unset)', " phone=").concat((e === null || e === void 0 ? void 0 : e.phone) || '(unset)'); };
|
|
485
|
+
makeVictim = function (tag, fields) {
|
|
486
|
+
if (fields === void 0) { fields = {}; }
|
|
487
|
+
return __awaiter(void 0, void 0, void 0, function () {
|
|
488
|
+
var victim;
|
|
489
|
+
return __generator(this, function (_a) {
|
|
490
|
+
switch (_a.label) {
|
|
491
|
+
case 0: return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ fname: "Lock".concat(RUN), lname: "Victim".concat(RUN).concat(tag), dateOfBirth: '01-02-1990' }, fields))];
|
|
492
|
+
case 1:
|
|
493
|
+
victim = _a.sent();
|
|
494
|
+
createdEnduserIds.push(victim.id);
|
|
495
|
+
return [2 /*return*/, victim];
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
VICTIM_SHAPES = [
|
|
501
|
+
{ tag: 'A1', label: 'victim has email + phone', withEmail: true, withPhone: true },
|
|
502
|
+
{ tag: 'A2', label: 'victim email UNSET (load-bearing)', withEmail: false, withPhone: true },
|
|
503
|
+
{ tag: 'A3', label: 'victim phone UNSET', withEmail: true, withPhone: false },
|
|
504
|
+
{ tag: 'A4', label: 'victim email + phone UNSET (load-bearing)', withEmail: false, withPhone: false },
|
|
505
|
+
];
|
|
506
|
+
_loop_1 = function (shape) {
|
|
507
|
+
var name_1;
|
|
508
|
+
return __generator(this, function (_s) {
|
|
509
|
+
switch (_s.label) {
|
|
510
|
+
case 0:
|
|
511
|
+
name_1 = "A blocks email/phone overwrite \u2014 ".concat(shape.label);
|
|
512
|
+
return [4 /*yield*/, runScenario('G1', name_1, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
513
|
+
var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
|
|
514
|
+
return __generator(this, function (_b) {
|
|
515
|
+
switch (_b.label) {
|
|
516
|
+
case 0: return [4 /*yield*/, makeVictim(shape.tag, __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-v-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
|
|
517
|
+
case 1:
|
|
518
|
+
victim = _b.sent();
|
|
519
|
+
attackerEmail = emailFor("atk-".concat(shape.tag));
|
|
520
|
+
attackerPhone = nextPhone();
|
|
521
|
+
return [4 /*yield*/, startForm({
|
|
522
|
+
fname: victim.fname,
|
|
523
|
+
lname: victim.lname,
|
|
524
|
+
dateOfBirth: victim.dateOfBirth,
|
|
525
|
+
email: attackerEmail,
|
|
526
|
+
phone: attackerPhone,
|
|
527
|
+
})
|
|
528
|
+
// Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
|
|
529
|
+
// unchanged and the test would pass for the wrong reason.
|
|
530
|
+
];
|
|
531
|
+
case 2:
|
|
532
|
+
session = _b.sent();
|
|
533
|
+
// Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
|
|
534
|
+
// unchanged and the test would pass for the wrong reason.
|
|
535
|
+
if (session.enduserId !== victim.id) {
|
|
536
|
+
if (session.enduserId)
|
|
537
|
+
createdEnduserIds.push(session.enduserId);
|
|
538
|
+
record('G1', name_1, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
539
|
+
return [2 /*return*/];
|
|
540
|
+
}
|
|
541
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
|
|
542
|
+
case 3:
|
|
543
|
+
_a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
|
|
544
|
+
record('G1', name_1, !landed, landed
|
|
545
|
+
? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
|
|
546
|
+
: "unchanged (".concat(describe(enduser), ")"));
|
|
547
|
+
return [2 /*return*/];
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}); })];
|
|
551
|
+
case 1:
|
|
552
|
+
_s.sent();
|
|
553
|
+
return [2 /*return*/];
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
_i = 0, VICTIM_SHAPES_1 = VICTIM_SHAPES;
|
|
558
|
+
_r.label = 14;
|
|
559
|
+
case 14:
|
|
560
|
+
if (!(_i < VICTIM_SHAPES_1.length)) return [3 /*break*/, 17];
|
|
561
|
+
shape = VICTIM_SHAPES_1[_i];
|
|
562
|
+
return [5 /*yield**/, _loop_1(shape)];
|
|
563
|
+
case 15:
|
|
564
|
+
_r.sent();
|
|
565
|
+
_r.label = 16;
|
|
566
|
+
case 16:
|
|
567
|
+
_i++;
|
|
568
|
+
return [3 /*break*/, 14];
|
|
569
|
+
case 17:
|
|
570
|
+
_loop_2 = function (shape) {
|
|
571
|
+
var name_2;
|
|
572
|
+
return __generator(this, function (_t) {
|
|
573
|
+
switch (_t.label) {
|
|
574
|
+
case 0:
|
|
575
|
+
name_2 = "B blocks email/phone overwrite \u2014 ".concat(shape.label);
|
|
576
|
+
return [4 /*yield*/, runScenario('G2', name_2, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
577
|
+
var victim, attackerEmail, attackerPhone, matching, _i, matching_1, m, _a, landed, enduser;
|
|
578
|
+
return __generator(this, function (_b) {
|
|
579
|
+
switch (_b.label) {
|
|
580
|
+
case 0: return [4 /*yield*/, makeVictim("B".concat(shape.tag), __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-vb-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
|
|
581
|
+
case 1:
|
|
582
|
+
victim = _b.sent();
|
|
583
|
+
attackerEmail = emailFor("atkb-".concat(shape.tag));
|
|
584
|
+
attackerPhone = nextPhone();
|
|
585
|
+
return [4 /*yield*/, startBooking({
|
|
586
|
+
fname: victim.fname,
|
|
587
|
+
lname: victim.lname,
|
|
588
|
+
dateOfBirth: victim.dateOfBirth,
|
|
589
|
+
email: attackerEmail,
|
|
590
|
+
phone: attackerPhone,
|
|
591
|
+
})];
|
|
592
|
+
case 2:
|
|
593
|
+
_b.sent();
|
|
594
|
+
return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: victim.lname } })];
|
|
595
|
+
case 3:
|
|
596
|
+
matching = _b.sent();
|
|
597
|
+
if (matching.length !== 1 || matching[0].id !== victim.id) {
|
|
598
|
+
for (_i = 0, matching_1 = matching; _i < matching_1.length; _i++) {
|
|
599
|
+
m = matching_1[_i];
|
|
600
|
+
if (m.id !== victim.id)
|
|
601
|
+
createdEnduserIds.push(m.id);
|
|
602
|
+
}
|
|
603
|
+
record('G2', name_2, false, "probe invalid \u2014 ".concat(matching.length, " records match lname ").concat(victim.lname, "; endpoint inserted instead of matching"));
|
|
604
|
+
return [2 /*return*/];
|
|
605
|
+
}
|
|
606
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, AWAITED_WINDOW_MS)];
|
|
607
|
+
case 4:
|
|
608
|
+
_a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
|
|
609
|
+
record('G2', name_2, !landed, landed
|
|
610
|
+
? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
|
|
611
|
+
: "unchanged (".concat(describe(enduser), ")"));
|
|
612
|
+
return [2 /*return*/];
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
}); })];
|
|
616
|
+
case 1:
|
|
617
|
+
_t.sent();
|
|
618
|
+
return [2 /*return*/];
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
};
|
|
622
|
+
_b = 0, VICTIM_SHAPES_2 = VICTIM_SHAPES;
|
|
623
|
+
_r.label = 18;
|
|
624
|
+
case 18:
|
|
625
|
+
if (!(_b < VICTIM_SHAPES_2.length)) return [3 /*break*/, 21];
|
|
626
|
+
shape = VICTIM_SHAPES_2[_b];
|
|
627
|
+
return [5 /*yield**/, _loop_2(shape)];
|
|
628
|
+
case 19:
|
|
629
|
+
_r.sent();
|
|
630
|
+
_r.label = 20;
|
|
631
|
+
case 20:
|
|
632
|
+
_b++;
|
|
633
|
+
return [3 /*break*/, 18];
|
|
634
|
+
case 21:
|
|
635
|
+
// ---------------------------------------------------------------------------------------------
|
|
636
|
+
// G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
|
|
637
|
+
// as an attack through the first-party frontend, but trivially reachable by POSTing directly.
|
|
638
|
+
// The field lock must hold on this path too.
|
|
639
|
+
// ---------------------------------------------------------------------------------------------
|
|
640
|
+
return [4 /*yield*/, runScenario('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
641
|
+
var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
|
|
642
|
+
return __generator(this, function (_b) {
|
|
643
|
+
switch (_b.label) {
|
|
644
|
+
case 0: return [4 /*yield*/, makeVictim('C1')]; // no email, no phone — the takeover-ready shape
|
|
645
|
+
case 1:
|
|
646
|
+
victim = _b.sent() // no email, no phone — the takeover-ready shape
|
|
647
|
+
;
|
|
648
|
+
attackerEmail = emailFor('atk-c1');
|
|
649
|
+
attackerPhone = nextPhone();
|
|
650
|
+
return [4 /*yield*/, startForm({ enduserId: victim.id, email: attackerEmail, phone: attackerPhone })];
|
|
651
|
+
case 2:
|
|
652
|
+
session = _b.sent();
|
|
653
|
+
if (session.enduserId !== victim.id) {
|
|
654
|
+
if (session.enduserId)
|
|
655
|
+
createdEnduserIds.push(session.enduserId);
|
|
656
|
+
record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', false, "probe invalid \u2014 endpoint returned ".concat(session.enduserId, ", expected victim ").concat(victim.id));
|
|
657
|
+
return [2 /*return*/];
|
|
658
|
+
}
|
|
659
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
|
|
660
|
+
case 3:
|
|
661
|
+
_a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
|
|
662
|
+
record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', !landed, landed
|
|
663
|
+
? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
|
|
664
|
+
: "unchanged (".concat(describe(enduser), ")"));
|
|
665
|
+
return [2 /*return*/];
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}); })
|
|
669
|
+
// ---------------------------------------------------------------------------------------------
|
|
670
|
+
// G4 — The takeover chain itself.
|
|
671
|
+
//
|
|
672
|
+
// access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
|
|
673
|
+
// no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
|
|
674
|
+
// no-match and throttled alike — so it carries zero signal. Two sound substitutes:
|
|
675
|
+
// 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
|
|
676
|
+
// predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
|
|
677
|
+
// minting anything, so an empty set proves non-issuance.
|
|
678
|
+
// 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
|
|
679
|
+
// On a vulnerable server the now-email-bearing, password-less victim flips this to
|
|
680
|
+
// sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
|
|
681
|
+
// Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
|
|
682
|
+
// two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
|
|
683
|
+
// failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
|
|
684
|
+
// this file runs as part of tests.ts).
|
|
685
|
+
// ---------------------------------------------------------------------------------------------
|
|
686
|
+
];
|
|
687
|
+
case 22:
|
|
688
|
+
// ---------------------------------------------------------------------------------------------
|
|
689
|
+
// G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
|
|
690
|
+
// as an attack through the first-party frontend, but trivially reachable by POSTing directly.
|
|
691
|
+
// The field lock must hold on this path too.
|
|
692
|
+
// ---------------------------------------------------------------------------------------------
|
|
693
|
+
_r.sent();
|
|
694
|
+
// ---------------------------------------------------------------------------------------------
|
|
695
|
+
// G4 — The takeover chain itself.
|
|
696
|
+
//
|
|
697
|
+
// access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
|
|
698
|
+
// no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
|
|
699
|
+
// no-match and throttled alike — so it carries zero signal. Two sound substitutes:
|
|
700
|
+
// 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
|
|
701
|
+
// predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
|
|
702
|
+
// minting anything, so an empty set proves non-issuance.
|
|
703
|
+
// 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
|
|
704
|
+
// On a vulnerable server the now-email-bearing, password-less victim flips this to
|
|
705
|
+
// sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
|
|
706
|
+
// Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
|
|
707
|
+
// two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
|
|
708
|
+
// failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
|
|
709
|
+
// this file runs as part of tests.ts).
|
|
710
|
+
// ---------------------------------------------------------------------------------------------
|
|
711
|
+
return [4 /*yield*/, runScenario('G4', 'takeover chain: email-less victim is not made resolvable by A', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
712
|
+
var victim, attackerEmail, baseline, baselineResult, session, enduser, resetRes, resolvable, after, afterResult;
|
|
713
|
+
var _a, _b;
|
|
714
|
+
return __generator(this, function (_c) {
|
|
715
|
+
switch (_c.label) {
|
|
716
|
+
case 0: return [4 /*yield*/, makeVictim('D1', { phone: nextPhone() })]; // email-less: the common, cheap case
|
|
717
|
+
case 1:
|
|
718
|
+
victim = _c.sent() // email-less: the common, cheap case
|
|
719
|
+
;
|
|
720
|
+
attackerEmail = emailFor('atk-d1');
|
|
721
|
+
return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
|
|
722
|
+
case 2:
|
|
723
|
+
baseline = _c.sent();
|
|
724
|
+
baselineResult = (_a = baseline.data) === null || _a === void 0 ? void 0 : _a.result;
|
|
725
|
+
if (baseline.status === 429) {
|
|
726
|
+
skip('G4', 'begin_login_flow oracle baseline', 'rate limited (429) — oracle unavailable this run');
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
record('G4', 'begin_login_flow oracle baseline is continue-with-password', baselineResult === 'continue-with-password', "result=".concat(baselineResult, " status=").concat(baseline.status));
|
|
730
|
+
}
|
|
731
|
+
return [4 /*yield*/, startForm({
|
|
732
|
+
fname: victim.fname,
|
|
733
|
+
lname: victim.lname,
|
|
734
|
+
dateOfBirth: victim.dateOfBirth,
|
|
735
|
+
email: attackerEmail,
|
|
736
|
+
phone: nextPhone(),
|
|
737
|
+
})];
|
|
738
|
+
case 3:
|
|
739
|
+
session = _c.sent();
|
|
740
|
+
if (session.enduserId !== victim.id) {
|
|
741
|
+
if (session.enduserId)
|
|
742
|
+
createdEnduserIds.push(session.enduserId);
|
|
743
|
+
record('G4', 'takeover chain: email-less victim is not made resolvable by A', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
744
|
+
return [2 /*return*/];
|
|
745
|
+
}
|
|
746
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
|
|
747
|
+
// Informational only: 204 regardless of outcome.
|
|
748
|
+
];
|
|
749
|
+
case 4:
|
|
750
|
+
enduser = (_c.sent()).enduser;
|
|
751
|
+
return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
|
|
752
|
+
case 5:
|
|
753
|
+
resetRes = _c.sent();
|
|
754
|
+
console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
|
|
755
|
+
return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
|
|
756
|
+
case 6:
|
|
757
|
+
resolvable = _c.sent();
|
|
758
|
+
record('G4', 'attacker email resolves to no enduser (password reset cannot be issued)', resolvable.length === 0, resolvable.length === 0
|
|
759
|
+
? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
|
|
760
|
+
: "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be minted and mailed to the attacker"));
|
|
761
|
+
return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
|
|
762
|
+
case 7:
|
|
763
|
+
after = _c.sent();
|
|
764
|
+
afterResult = (_b = after.data) === null || _b === void 0 ? void 0 : _b.result;
|
|
765
|
+
if (after.status === 429) {
|
|
766
|
+
skip('G4', 'begin_login_flow oracle after attack', 'rate limited (429) — oracle unavailable this run');
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
record('G4', 'begin_login_flow still returns continue-with-password after attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
|
|
770
|
+
? "result=".concat(afterResult)
|
|
771
|
+
: "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
|
|
772
|
+
}
|
|
773
|
+
return [2 /*return*/];
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}); })
|
|
777
|
+
// ---------------------------------------------------------------------------------------------
|
|
778
|
+
// G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
|
|
779
|
+
// ---------------------------------------------------------------------------------------------
|
|
780
|
+
];
|
|
781
|
+
case 23:
|
|
782
|
+
// ---------------------------------------------------------------------------------------------
|
|
783
|
+
// G4 — The takeover chain itself.
|
|
784
|
+
//
|
|
785
|
+
// access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
|
|
786
|
+
// no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
|
|
787
|
+
// no-match and throttled alike — so it carries zero signal. Two sound substitutes:
|
|
788
|
+
// 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
|
|
789
|
+
// predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
|
|
790
|
+
// minting anything, so an empty set proves non-issuance.
|
|
791
|
+
// 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
|
|
792
|
+
// On a vulnerable server the now-email-bearing, password-less victim flips this to
|
|
793
|
+
// sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
|
|
794
|
+
// Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
|
|
795
|
+
// two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
|
|
796
|
+
// failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
|
|
797
|
+
// this file runs as part of tests.ts).
|
|
798
|
+
// ---------------------------------------------------------------------------------------------
|
|
799
|
+
_r.sent();
|
|
800
|
+
// ---------------------------------------------------------------------------------------------
|
|
801
|
+
// G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
|
|
802
|
+
// ---------------------------------------------------------------------------------------------
|
|
803
|
+
return [4 /*yield*/, runScenario('G5', 'blank match keys filled on match, blank phone NOT filled', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
804
|
+
var victimEmail, victim, submittedPhone, session, filled, phoneLanded;
|
|
805
|
+
var _a, _b, _c, _d, _e;
|
|
806
|
+
return __generator(this, function (_f) {
|
|
807
|
+
switch (_f.label) {
|
|
808
|
+
case 0:
|
|
809
|
+
victimEmail = emailFor('lock-v-e1');
|
|
810
|
+
return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ email: victimEmail }, (MATCH_EMAIL_AND_NAMES ? { fname: "Lock".concat(RUN), lname: "Victim".concat(RUN, "E1") } : {})))];
|
|
811
|
+
case 1:
|
|
812
|
+
victim = _f.sent();
|
|
813
|
+
createdEnduserIds.push(victim.id);
|
|
814
|
+
if (MATCH_EMAIL_AND_NAMES) {
|
|
815
|
+
skip('G5', 'blank fname/lname fill', 'organization has settings.endusers.matchEmailAndNames — a name-less record is unmatchable');
|
|
816
|
+
}
|
|
817
|
+
submittedPhone = nextPhone();
|
|
818
|
+
return [4 /*yield*/, startForm({
|
|
819
|
+
email: victimEmail,
|
|
820
|
+
fname: "Lock".concat(RUN),
|
|
821
|
+
lname: "Victim".concat(RUN, "E1"),
|
|
822
|
+
dateOfBirth: '03-04-1991',
|
|
823
|
+
gender: 'Female',
|
|
824
|
+
phone: submittedPhone,
|
|
825
|
+
})];
|
|
826
|
+
case 2:
|
|
827
|
+
session = _f.sent();
|
|
828
|
+
if (session.enduserId !== victim.id) {
|
|
829
|
+
if (session.enduserId)
|
|
830
|
+
createdEnduserIds.push(session.enduserId);
|
|
831
|
+
record('G5', 'blank match keys filled on match, blank phone NOT filled', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
832
|
+
return [2 /*return*/];
|
|
833
|
+
}
|
|
834
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.dateOfBirth && !!e.gender && (MATCH_EMAIL_AND_NAMES || (!!e.fname && !!e.lname)); }, OVERWRITE_WINDOW_MS)];
|
|
835
|
+
case 3:
|
|
836
|
+
filled = _f.sent();
|
|
837
|
+
record('G5', 'blank fname/lname/dateOfBirth/gender still filled on match', filled.landed, "fname=".concat(((_a = filled.enduser) === null || _a === void 0 ? void 0 : _a.fname) || '(unset)', " lname=").concat(((_b = filled.enduser) === null || _b === void 0 ? void 0 : _b.lname) || '(unset)', " ")
|
|
838
|
+
+ "dateOfBirth=".concat(((_c = filled.enduser) === null || _c === void 0 ? void 0 : _c.dateOfBirth) || '(unset)', " gender=").concat(((_d = filled.enduser) === null || _d === void 0 ? void 0 : _d.gender) || '(unset)'));
|
|
839
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.phone; }, AWAITED_WINDOW_MS)];
|
|
840
|
+
case 4:
|
|
841
|
+
phoneLanded = _f.sent();
|
|
842
|
+
record('G5', 'blank phone still NOT filled on match', !phoneLanded.landed, phoneLanded.landed
|
|
843
|
+
? "EXPLOIT: phone=".concat((_e = phoneLanded.enduser) === null || _e === void 0 ? void 0 : _e.phone, " written into a blank field (submitted ").concat(submittedPhone, ")")
|
|
844
|
+
: 'phone remains unset');
|
|
845
|
+
return [2 /*return*/];
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
}); })
|
|
849
|
+
// ---------------------------------------------------------------------------------------------
|
|
850
|
+
// G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
|
|
851
|
+
// state is not an auth channel, and locking it would break a relocated patient booking with a
|
|
852
|
+
// provider licensed in their new state.
|
|
853
|
+
// ---------------------------------------------------------------------------------------------
|
|
854
|
+
];
|
|
855
|
+
case 24:
|
|
856
|
+
// ---------------------------------------------------------------------------------------------
|
|
857
|
+
// G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
|
|
858
|
+
// ---------------------------------------------------------------------------------------------
|
|
859
|
+
_r.sent();
|
|
860
|
+
// ---------------------------------------------------------------------------------------------
|
|
861
|
+
// G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
|
|
862
|
+
// state is not an auth channel, and locking it would break a relocated patient booking with a
|
|
863
|
+
// provider licensed in their new state.
|
|
864
|
+
// ---------------------------------------------------------------------------------------------
|
|
865
|
+
return [4 /*yield*/, runScenario('G6', 'state still overwrites on match (status-quo pin)', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
866
|
+
var victim, session, _a, landed, enduser;
|
|
867
|
+
return __generator(this, function (_b) {
|
|
868
|
+
switch (_b.label) {
|
|
869
|
+
case 0: return [4 /*yield*/, makeVictim('F1', { email: emailFor('lock-v-f1'), state: 'NY' })];
|
|
870
|
+
case 1:
|
|
871
|
+
victim = _b.sent();
|
|
872
|
+
return [4 /*yield*/, startForm({
|
|
873
|
+
fname: victim.fname,
|
|
874
|
+
lname: victim.lname,
|
|
875
|
+
dateOfBirth: victim.dateOfBirth,
|
|
876
|
+
phone: nextPhone(),
|
|
877
|
+
state: 'CA',
|
|
878
|
+
})];
|
|
879
|
+
case 2:
|
|
880
|
+
session = _b.sent();
|
|
881
|
+
if (session.enduserId !== victim.id) {
|
|
882
|
+
if (session.enduserId)
|
|
883
|
+
createdEnduserIds.push(session.enduserId);
|
|
884
|
+
record('G6', 'state still overwrites on match (status-quo pin)', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
885
|
+
return [2 /*return*/];
|
|
886
|
+
}
|
|
887
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.state === 'CA'; }, OVERWRITE_WINDOW_MS)];
|
|
888
|
+
case 3:
|
|
889
|
+
_a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
|
|
890
|
+
record('G6', 'state still overwrites on match (status-quo pin)', landed, "state=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.state) || '(unset)'));
|
|
891
|
+
return [2 /*return*/];
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
}); })
|
|
895
|
+
// ---------------------------------------------------------------------------------------------
|
|
896
|
+
// G7 — Insert path unchanged: a brand-new visitor is still created with the submitted email/phone.
|
|
897
|
+
// ---------------------------------------------------------------------------------------------
|
|
898
|
+
// Accumulator arrays rather than `let ... | null`: values are assigned inside runScenario's
|
|
899
|
+
// callback, and TS's control-flow narrowing does not account for closure assignments.
|
|
900
|
+
];
|
|
901
|
+
case 25:
|
|
902
|
+
// ---------------------------------------------------------------------------------------------
|
|
903
|
+
// G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
|
|
904
|
+
// state is not an auth channel, and locking it would break a relocated patient booking with a
|
|
905
|
+
// provider licensed in their new state.
|
|
906
|
+
// ---------------------------------------------------------------------------------------------
|
|
907
|
+
_r.sent();
|
|
908
|
+
formSessions = [];
|
|
909
|
+
return [4 /*yield*/, runScenario('G7', 'A creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
910
|
+
var email, phone, session, created, ok;
|
|
911
|
+
return __generator(this, function (_a) {
|
|
912
|
+
switch (_a.label) {
|
|
913
|
+
case 0:
|
|
914
|
+
email = emailFor('lock-new-a');
|
|
915
|
+
phone = nextPhone();
|
|
916
|
+
return [4 /*yield*/, startForm({
|
|
917
|
+
fname: "Lock".concat(RUN), lname: "NewA".concat(RUN), dateOfBirth: '05-06-1992',
|
|
918
|
+
email: email,
|
|
919
|
+
phone: phone,
|
|
920
|
+
})];
|
|
921
|
+
case 1:
|
|
922
|
+
session = _a.sent();
|
|
923
|
+
if (session.enduserId)
|
|
924
|
+
createdEnduserIds.push(session.enduserId);
|
|
925
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
|
|
926
|
+
case 2:
|
|
927
|
+
created = _a.sent();
|
|
928
|
+
ok = created.email === email && created.phone === phone
|
|
929
|
+
&& !!session.accessCode && !!session.authToken && !!session.enduserId;
|
|
930
|
+
record('G7', 'A creates a new visitor with submitted email + phone', ok, "".concat(describe(created), "; accessCode=").concat(!!session.accessCode, " authToken=").concat(!!session.authToken, " enduserId=").concat(!!session.enduserId));
|
|
931
|
+
if (ok)
|
|
932
|
+
formSessions.push(session);
|
|
933
|
+
return [2 /*return*/];
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
}); })];
|
|
937
|
+
case 26:
|
|
938
|
+
_r.sent();
|
|
939
|
+
bookingTokens = [];
|
|
940
|
+
return [4 /*yield*/, runScenario('G7', 'B creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
941
|
+
var email, phone, lname, session, matching, _i, matching_2, m, created, ok;
|
|
942
|
+
return __generator(this, function (_a) {
|
|
943
|
+
switch (_a.label) {
|
|
944
|
+
case 0:
|
|
945
|
+
email = emailFor('lock-new-b');
|
|
946
|
+
phone = nextPhone();
|
|
947
|
+
lname = "NewB".concat(RUN);
|
|
948
|
+
return [4 /*yield*/, startBooking({
|
|
949
|
+
fname: "Lock".concat(RUN),
|
|
950
|
+
lname: lname,
|
|
951
|
+
dateOfBirth: '07-08-1993',
|
|
952
|
+
email: email,
|
|
953
|
+
phone: phone,
|
|
954
|
+
})];
|
|
955
|
+
case 1:
|
|
956
|
+
session = _a.sent();
|
|
957
|
+
return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: lname } })];
|
|
958
|
+
case 2:
|
|
959
|
+
matching = _a.sent();
|
|
960
|
+
for (_i = 0, matching_2 = matching; _i < matching_2.length; _i++) {
|
|
961
|
+
m = matching_2[_i];
|
|
962
|
+
createdEnduserIds.push(m.id);
|
|
963
|
+
}
|
|
964
|
+
created = matching.length === 1 ? matching[0] : null;
|
|
965
|
+
ok = !!session.authToken && !!created && created.email === email && created.phone === phone;
|
|
966
|
+
record('G7', 'B creates a new visitor with submitted email + phone', ok, "authToken=".concat(!!session.authToken, "; ").concat(matching.length, " record(s); ").concat(describe(created)));
|
|
967
|
+
if (session.authToken)
|
|
968
|
+
bookingTokens.push(session.authToken);
|
|
969
|
+
return [2 /*return*/];
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
}); })
|
|
973
|
+
// ---------------------------------------------------------------------------------------------
|
|
974
|
+
// G8 — The returned public-form session still works end-to-end.
|
|
975
|
+
// ---------------------------------------------------------------------------------------------
|
|
976
|
+
];
|
|
977
|
+
case 27:
|
|
978
|
+
_r.sent();
|
|
979
|
+
// ---------------------------------------------------------------------------------------------
|
|
980
|
+
// G8 — The returned public-form session still works end-to-end.
|
|
981
|
+
// ---------------------------------------------------------------------------------------------
|
|
982
|
+
return [4 /*yield*/, runScenario('G8', 'form submission completes under the returned token', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
983
|
+
var session, authed, formResponse;
|
|
984
|
+
return __generator(this, function (_a) {
|
|
985
|
+
switch (_a.label) {
|
|
986
|
+
case 0:
|
|
987
|
+
session = formSessions[0];
|
|
988
|
+
if (!session) {
|
|
989
|
+
record('G8', 'form submission completes under the returned token', false, 'no session available from G7');
|
|
990
|
+
return [2 /*return*/];
|
|
991
|
+
}
|
|
992
|
+
authed = new EnduserSession({ host: host, businessId: businessId, authToken: session.authToken });
|
|
993
|
+
return [4 /*yield*/, authed.api.form_responses.submit_form_response({
|
|
994
|
+
accessCode: session.accessCode,
|
|
995
|
+
responses: [{
|
|
996
|
+
fieldId: plainField.id,
|
|
997
|
+
fieldTitle: plainField.title,
|
|
998
|
+
answer: { type: 'string', value: "lock test ".concat(RUN) },
|
|
999
|
+
}],
|
|
1000
|
+
})];
|
|
1001
|
+
case 1:
|
|
1002
|
+
formResponse = (_a.sent()).formResponse;
|
|
1003
|
+
record('G8', 'form submission completes under the returned token', !!(formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt), "formResponse=".concat(formResponse === null || formResponse === void 0 ? void 0 : formResponse.id, " submittedAt=").concat((formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt) ? 'set' : '(unset)'));
|
|
1004
|
+
return [2 /*return*/];
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
}); })
|
|
1008
|
+
// ---------------------------------------------------------------------------------------------
|
|
1009
|
+
// G9 — The returned booking session still works. Tiered: availability is always asserted; an
|
|
1010
|
+
// actual booking is attempted only when a slot exists, so an empty window never reads as a
|
|
1011
|
+
// regression.
|
|
1012
|
+
// ---------------------------------------------------------------------------------------------
|
|
1013
|
+
];
|
|
1014
|
+
case 28:
|
|
1015
|
+
// ---------------------------------------------------------------------------------------------
|
|
1016
|
+
// G8 — The returned public-form session still works end-to-end.
|
|
1017
|
+
// ---------------------------------------------------------------------------------------------
|
|
1018
|
+
_r.sent();
|
|
1019
|
+
// ---------------------------------------------------------------------------------------------
|
|
1020
|
+
// G9 — The returned booking session still works. Tiered: availability is always asserted; an
|
|
1021
|
+
// actual booking is attempted only when a slot exists, so an empty window never reads as a
|
|
1022
|
+
// regression.
|
|
1023
|
+
// ---------------------------------------------------------------------------------------------
|
|
1024
|
+
return [4 /*yield*/, runScenario('G9', 'availability + booking work under the returned session', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1025
|
+
var bookingAuthToken, booking, DAY_IN_MS, from, to, availabilityBlocks, slots, createdEvent;
|
|
1026
|
+
return __generator(this, function (_a) {
|
|
1027
|
+
switch (_a.label) {
|
|
1028
|
+
case 0:
|
|
1029
|
+
bookingAuthToken = bookingTokens[0];
|
|
1030
|
+
if (!bookingAuthToken) {
|
|
1031
|
+
record('G9', 'availability + booking work under the returned session', false, 'no booking session available from G7');
|
|
1032
|
+
return [2 /*return*/];
|
|
1033
|
+
}
|
|
1034
|
+
booking = new EnduserSession({ host: host, businessId: businessId, authToken: bookingAuthToken });
|
|
1035
|
+
DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
1036
|
+
from = new Date(Date.now() + DAY_IN_MS);
|
|
1037
|
+
to = new Date(from.getTime() + (7 * DAY_IN_MS));
|
|
1038
|
+
return [4 /*yield*/, booking.api.calendar_events.get_appointment_availability({
|
|
1039
|
+
calendarEventTemplateId: template.id,
|
|
1040
|
+
userIds: [hostUser.id],
|
|
1041
|
+
restrictedByState: false,
|
|
1042
|
+
from: from,
|
|
1043
|
+
to: to,
|
|
1044
|
+
})];
|
|
1045
|
+
case 1:
|
|
1046
|
+
availabilityBlocks = (_a.sent()).availabilityBlocks;
|
|
1047
|
+
slots = availabilityBlocks.filter(function (b) { return b.userId === hostUser.id && b.startTimeInMS > Date.now(); });
|
|
1048
|
+
record('G9', 'get_appointment_availability succeeds under the confined booking token', true, "".concat(availabilityBlocks.length, " block(s), ").concat(slots.length, " future slot(s) for the throwaway host"));
|
|
1049
|
+
if (slots.length === 0) {
|
|
1050
|
+
skip('G9', 'book_appointment under the confined booking token', 'no future availability slot in the 7-day window');
|
|
1051
|
+
return [2 /*return*/];
|
|
1052
|
+
}
|
|
1053
|
+
return [4 /*yield*/, booking.api.calendar_events.book_appointment({
|
|
1054
|
+
userId: hostUser.id,
|
|
1055
|
+
calendarEventTemplateId: template.id,
|
|
1056
|
+
startTime: new Date(slots[0].startTimeInMS),
|
|
1057
|
+
timezone: 'America/New_York',
|
|
1058
|
+
})];
|
|
1059
|
+
case 2:
|
|
1060
|
+
createdEvent = (_a.sent()).createdEvent;
|
|
1061
|
+
if (createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id)
|
|
1062
|
+
createdEventIds.push(createdEvent.id);
|
|
1063
|
+
record('G9', 'book_appointment succeeds under the confined booking token', !!(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id), "createdEvent=".concat(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id));
|
|
1064
|
+
return [2 /*return*/];
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
}); })
|
|
1068
|
+
// ---------------------------------------------------------------------------------------------
|
|
1069
|
+
// G10 — skipMatch behavior unchanged.
|
|
1070
|
+
// ---------------------------------------------------------------------------------------------
|
|
1071
|
+
];
|
|
1072
|
+
case 29:
|
|
1073
|
+
// ---------------------------------------------------------------------------------------------
|
|
1074
|
+
// G9 — The returned booking session still works. Tiered: availability is always asserted; an
|
|
1075
|
+
// actual booking is attempted only when a slot exists, so an empty window never reads as a
|
|
1076
|
+
// regression.
|
|
1077
|
+
// ---------------------------------------------------------------------------------------------
|
|
1078
|
+
_r.sent();
|
|
1079
|
+
// ---------------------------------------------------------------------------------------------
|
|
1080
|
+
// G10 — skipMatch behavior unchanged.
|
|
1081
|
+
// ---------------------------------------------------------------------------------------------
|
|
1082
|
+
return [4 /*yield*/, runScenario('G10', 'skipMatch=true still inserts a new record', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1083
|
+
var victim, session;
|
|
1084
|
+
return __generator(this, function (_a) {
|
|
1085
|
+
switch (_a.label) {
|
|
1086
|
+
case 0: return [4 /*yield*/, makeVictim('H1', { email: emailFor('lock-v-h1'), phone: nextPhone() })];
|
|
1087
|
+
case 1:
|
|
1088
|
+
victim = _a.sent();
|
|
1089
|
+
return [4 /*yield*/, startForm({
|
|
1090
|
+
fname: victim.fname,
|
|
1091
|
+
lname: victim.lname,
|
|
1092
|
+
dateOfBirth: victim.dateOfBirth,
|
|
1093
|
+
email: emailFor('lock-skip'),
|
|
1094
|
+
phone: nextPhone(),
|
|
1095
|
+
skipMatch: true,
|
|
1096
|
+
})];
|
|
1097
|
+
case 2:
|
|
1098
|
+
session = _a.sent();
|
|
1099
|
+
if (session.enduserId)
|
|
1100
|
+
createdEnduserIds.push(session.enduserId);
|
|
1101
|
+
record('G10', 'skipMatch=true still inserts a new record', session.enduserId !== victim.id, "inserted=".concat(session.enduserId, " victim=").concat(victim.id));
|
|
1102
|
+
return [2 /*return*/];
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}); })];
|
|
1106
|
+
case 30:
|
|
1107
|
+
// ---------------------------------------------------------------------------------------------
|
|
1108
|
+
// G10 — skipMatch behavior unchanged.
|
|
1109
|
+
// ---------------------------------------------------------------------------------------------
|
|
1110
|
+
_r.sent();
|
|
1111
|
+
return [4 /*yield*/, runScenario('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1112
|
+
var session, created;
|
|
1113
|
+
return __generator(this, function (_a) {
|
|
1114
|
+
switch (_a.label) {
|
|
1115
|
+
case 0: return [4 /*yield*/, publicSDK().api.form_responses.session_for_public_form({
|
|
1116
|
+
formId: autoMergeForm.id,
|
|
1117
|
+
businessId: businessId,
|
|
1118
|
+
skipMatch: true,
|
|
1119
|
+
fname: "Lock".concat(RUN),
|
|
1120
|
+
lname: "Merge".concat(RUN),
|
|
1121
|
+
dateOfBirth: '09-10-1994',
|
|
1122
|
+
email: emailFor('lock-merge'),
|
|
1123
|
+
phone: nextPhone(),
|
|
1124
|
+
})];
|
|
1125
|
+
case 1:
|
|
1126
|
+
session = _a.sent();
|
|
1127
|
+
if (session.enduserId)
|
|
1128
|
+
createdEnduserIds.push(session.enduserId);
|
|
1129
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
|
|
1130
|
+
case 2:
|
|
1131
|
+
created = _a.sent();
|
|
1132
|
+
record('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', created.eligibleForAutoMerge === true, "eligibleForAutoMerge=".concat(created.eligibleForAutoMerge));
|
|
1133
|
+
return [2 /*return*/];
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
}); })
|
|
1137
|
+
// ---------------------------------------------------------------------------------------------
|
|
1138
|
+
// G11 — Intake answers must not overwrite email/phone on a matched patient (submit path).
|
|
1139
|
+
// ---------------------------------------------------------------------------------------------
|
|
1140
|
+
];
|
|
1141
|
+
case 31:
|
|
1142
|
+
_r.sent();
|
|
1143
|
+
_loop_3 = function (shape) {
|
|
1144
|
+
var name_3;
|
|
1145
|
+
return __generator(this, function (_u) {
|
|
1146
|
+
switch (_u.label) {
|
|
1147
|
+
case 0:
|
|
1148
|
+
name_3 = "intake answers do not overwrite a matched patient \u2014 ".concat(shape.label);
|
|
1149
|
+
return [4 /*yield*/, runScenario('G11', name_3, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1150
|
+
var victimEmail, victimPhone, victim, attackerEmail, attackerPhone, session, enduser, emailHeld, phoneHeld;
|
|
1151
|
+
return __generator(this, function (_a) {
|
|
1152
|
+
switch (_a.label) {
|
|
1153
|
+
case 0:
|
|
1154
|
+
victimEmail = shape.withEmail ? emailFor("lock-i-".concat(shape.tag)) : undefined;
|
|
1155
|
+
victimPhone = shape.withPhone ? nextPhone() : undefined;
|
|
1156
|
+
return [4 /*yield*/, makeVictim("I".concat(shape.tag), __assign(__assign({}, (victimEmail ? { email: victimEmail } : {})), (victimPhone ? { phone: victimPhone } : {})))];
|
|
1157
|
+
case 1:
|
|
1158
|
+
victim = _a.sent();
|
|
1159
|
+
attackerEmail = emailFor("atk-i-".concat(shape.tag));
|
|
1160
|
+
attackerPhone = nextPhone();
|
|
1161
|
+
return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
1162
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1163
|
+
})];
|
|
1164
|
+
case 2:
|
|
1165
|
+
session = _a.sent();
|
|
1166
|
+
if (session.enduserId)
|
|
1167
|
+
createdEnduserIds.push(session.enduserId);
|
|
1168
|
+
if (session.enduserId !== victim.id) {
|
|
1169
|
+
record('G11', name_3, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
1170
|
+
return [2 /*return*/];
|
|
1171
|
+
}
|
|
1172
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1173
|
+
accessCode: session.accessCode,
|
|
1174
|
+
responses: intakeAnswers(intake, attackerEmail, attackerPhone),
|
|
1175
|
+
})];
|
|
1176
|
+
case 3:
|
|
1177
|
+
_a.sent();
|
|
1178
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
|
|
1179
|
+
case 4:
|
|
1180
|
+
enduser = (_a.sent()).enduser;
|
|
1181
|
+
emailHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.email) || undefined) === victimEmail;
|
|
1182
|
+
phoneHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || undefined) === victimPhone;
|
|
1183
|
+
record('G11', "".concat(name_3, " \u2014 email"), emailHeld, emailHeld
|
|
1184
|
+
? "email still ".concat(victimEmail || '(unset)')
|
|
1185
|
+
: "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail || '(unset)', ")"));
|
|
1186
|
+
record('G11', "".concat(name_3, " \u2014 phone"), phoneHeld, phoneHeld
|
|
1187
|
+
? "phone still ".concat(victimPhone || '(unset)')
|
|
1188
|
+
: "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone || '(unset)', ")"));
|
|
1189
|
+
return [2 /*return*/];
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
}); })];
|
|
1193
|
+
case 1:
|
|
1194
|
+
_u.sent();
|
|
1195
|
+
return [2 /*return*/];
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
};
|
|
1199
|
+
_c = 0, VICTIM_SHAPES_3 = VICTIM_SHAPES;
|
|
1200
|
+
_r.label = 32;
|
|
1201
|
+
case 32:
|
|
1202
|
+
if (!(_c < VICTIM_SHAPES_3.length)) return [3 /*break*/, 35];
|
|
1203
|
+
shape = VICTIM_SHAPES_3[_c];
|
|
1204
|
+
return [5 /*yield**/, _loop_3(shape)];
|
|
1205
|
+
case 33:
|
|
1206
|
+
_r.sent();
|
|
1207
|
+
_r.label = 34;
|
|
1208
|
+
case 34:
|
|
1209
|
+
_c++;
|
|
1210
|
+
return [3 /*break*/, 32];
|
|
1211
|
+
case 35:
|
|
1212
|
+
G12_NAME = 'saveIntakeOnPartial answers do not overwrite a matched patient';
|
|
1213
|
+
return [4 /*yield*/, runScenario('G12', G12_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1214
|
+
var victim, attackerEmail, session, enduser;
|
|
1215
|
+
return __generator(this, function (_a) {
|
|
1216
|
+
switch (_a.label) {
|
|
1217
|
+
case 0: return [4 /*yield*/, makeVictim('J1', { phone: nextPhone() })]; // email-less: the load-bearing shape
|
|
1218
|
+
case 1:
|
|
1219
|
+
victim = _a.sent() // email-less: the load-bearing shape
|
|
1220
|
+
;
|
|
1221
|
+
attackerEmail = emailFor('atk-j1');
|
|
1222
|
+
return [4 /*yield*/, startFormOn(partialIntake.form.id, {
|
|
1223
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1224
|
+
})];
|
|
1225
|
+
case 2:
|
|
1226
|
+
session = _a.sent();
|
|
1227
|
+
if (session.enduserId)
|
|
1228
|
+
createdEnduserIds.push(session.enduserId);
|
|
1229
|
+
if (session.enduserId !== victim.id) {
|
|
1230
|
+
record('G12', G12_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
1231
|
+
return [2 /*return*/];
|
|
1232
|
+
}
|
|
1233
|
+
// No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
|
|
1234
|
+
// triggers the intake write.
|
|
1235
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.save_field_response({
|
|
1236
|
+
accessCode: session.accessCode,
|
|
1237
|
+
response: {
|
|
1238
|
+
fieldId: partialIntake.emailField.id,
|
|
1239
|
+
fieldTitle: partialIntake.emailField.title,
|
|
1240
|
+
answer: { type: 'email', value: attackerEmail },
|
|
1241
|
+
},
|
|
1242
|
+
})];
|
|
1243
|
+
case 3:
|
|
1244
|
+
// No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
|
|
1245
|
+
// triggers the intake write.
|
|
1246
|
+
_a.sent();
|
|
1247
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
|
|
1248
|
+
case 4:
|
|
1249
|
+
enduser = (_a.sent()).enduser;
|
|
1250
|
+
record('G12', G12_NAME, !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
|
|
1251
|
+
? 'email still (unset) after partial save'
|
|
1252
|
+
: "EXPLOIT: partial save wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " without ever submitting the form"));
|
|
1253
|
+
return [2 /*return*/];
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
}); })
|
|
1257
|
+
// ---------------------------------------------------------------------------------------------
|
|
1258
|
+
// G13 — The full takeover chain, driven through an intake answer instead of the endpoint.
|
|
1259
|
+
// ---------------------------------------------------------------------------------------------
|
|
1260
|
+
];
|
|
1261
|
+
case 36:
|
|
1262
|
+
_r.sent();
|
|
1263
|
+
G13_NAME = 'takeover chain via intake answer: victim not made resolvable';
|
|
1264
|
+
return [4 /*yield*/, runScenario('G13', G13_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1265
|
+
var victim, attackerEmail, session, enduser, resetRes, resolvable, after, afterResult;
|
|
1266
|
+
var _a;
|
|
1267
|
+
return __generator(this, function (_b) {
|
|
1268
|
+
switch (_b.label) {
|
|
1269
|
+
case 0: return [4 /*yield*/, makeVictim('K1', { phone: nextPhone() })];
|
|
1270
|
+
case 1:
|
|
1271
|
+
victim = _b.sent();
|
|
1272
|
+
attackerEmail = emailFor('atk-k1');
|
|
1273
|
+
return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
1274
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1275
|
+
})];
|
|
1276
|
+
case 2:
|
|
1277
|
+
session = _b.sent();
|
|
1278
|
+
if (session.enduserId)
|
|
1279
|
+
createdEnduserIds.push(session.enduserId);
|
|
1280
|
+
if (session.enduserId !== victim.id) {
|
|
1281
|
+
record('G13', G13_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
1282
|
+
return [2 /*return*/];
|
|
1283
|
+
}
|
|
1284
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1285
|
+
accessCode: session.accessCode,
|
|
1286
|
+
responses: intakeAnswers(intake, attackerEmail, nextPhone()),
|
|
1287
|
+
})];
|
|
1288
|
+
case 3:
|
|
1289
|
+
_b.sent();
|
|
1290
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
|
|
1291
|
+
// Informational only: 204 regardless of outcome.
|
|
1292
|
+
];
|
|
1293
|
+
case 4:
|
|
1294
|
+
enduser = (_b.sent()).enduser;
|
|
1295
|
+
return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
|
|
1296
|
+
case 5:
|
|
1297
|
+
resetRes = _b.sent();
|
|
1298
|
+
console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
|
|
1299
|
+
return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
|
|
1300
|
+
case 6:
|
|
1301
|
+
resolvable = _b.sent();
|
|
1302
|
+
record('G13', 'attacker email resolves to no enduser after intake submit', resolvable.length === 0, resolvable.length === 0
|
|
1303
|
+
? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
|
|
1304
|
+
: "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be mailed to the attacker"));
|
|
1305
|
+
return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
|
|
1306
|
+
case 7:
|
|
1307
|
+
after = _b.sent();
|
|
1308
|
+
afterResult = (_a = after.data) === null || _a === void 0 ? void 0 : _a.result;
|
|
1309
|
+
if (after.status === 429) {
|
|
1310
|
+
skip('G13', 'begin_login_flow oracle after intake attack', 'rate limited (429) — oracle unavailable this run');
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
record('G13', 'begin_login_flow still returns continue-with-password after intake attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
|
|
1314
|
+
? "result=".concat(afterResult)
|
|
1315
|
+
: "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
|
|
1316
|
+
}
|
|
1317
|
+
return [2 /*return*/];
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
}); })
|
|
1321
|
+
// ---------------------------------------------------------------------------------------------
|
|
1322
|
+
// G14 — Pivot: a public session can prepare a response for a NON-public form and submit that.
|
|
1323
|
+
// ---------------------------------------------------------------------------------------------
|
|
1324
|
+
];
|
|
1325
|
+
case 37:
|
|
1326
|
+
_r.sent();
|
|
1327
|
+
G14_NAME = 'pivot via prepare_form_response to a non-public form is also blocked';
|
|
1328
|
+
return [4 /*yield*/, runScenario('G14', G14_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1329
|
+
var victimPhone, victim, attackerEmail, attackerPhone, session, authed, prepared, enduser;
|
|
1330
|
+
return __generator(this, function (_a) {
|
|
1331
|
+
switch (_a.label) {
|
|
1332
|
+
case 0:
|
|
1333
|
+
victimPhone = nextPhone();
|
|
1334
|
+
return [4 /*yield*/, makeVictim('L1', { phone: victimPhone })];
|
|
1335
|
+
case 1:
|
|
1336
|
+
victim = _a.sent();
|
|
1337
|
+
attackerEmail = emailFor('atk-l1');
|
|
1338
|
+
attackerPhone = nextPhone();
|
|
1339
|
+
return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
1340
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1341
|
+
})];
|
|
1342
|
+
case 2:
|
|
1343
|
+
session = _a.sent();
|
|
1344
|
+
if (session.enduserId)
|
|
1345
|
+
createdEnduserIds.push(session.enduserId);
|
|
1346
|
+
if (session.enduserId !== victim.id) {
|
|
1347
|
+
record('G14', G14_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
1348
|
+
return [2 /*return*/];
|
|
1349
|
+
}
|
|
1350
|
+
authed = asEnduser(session.authToken);
|
|
1351
|
+
return [4 /*yield*/, authed.api.form_responses.prepare_form_response({
|
|
1352
|
+
formId: pivotIntake.form.id,
|
|
1353
|
+
enduserId: victim.id,
|
|
1354
|
+
})];
|
|
1355
|
+
case 3:
|
|
1356
|
+
prepared = _a.sent();
|
|
1357
|
+
return [4 /*yield*/, authed.api.form_responses.submit_form_response({
|
|
1358
|
+
accessCode: prepared.accessCode,
|
|
1359
|
+
responses: intakeAnswers(pivotIntake, attackerEmail, attackerPhone),
|
|
1360
|
+
})];
|
|
1361
|
+
case 4:
|
|
1362
|
+
_a.sent();
|
|
1363
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
|
|
1364
|
+
case 5:
|
|
1365
|
+
enduser = (_a.sent()).enduser;
|
|
1366
|
+
record('G14', "".concat(G14_NAME, " \u2014 email"), !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
|
|
1367
|
+
? 'email still (unset) after pivot submit'
|
|
1368
|
+
: "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email));
|
|
1369
|
+
record('G14', "".concat(G14_NAME, " \u2014 phone"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone, (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone
|
|
1370
|
+
? "phone still ".concat(victimPhone)
|
|
1371
|
+
: "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone, ")"));
|
|
1372
|
+
return [2 /*return*/];
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}); })
|
|
1376
|
+
// ---------------------------------------------------------------------------------------------
|
|
1377
|
+
// G15 — save_field_response ownership: a guessable formResponseId must not reach another patient.
|
|
1378
|
+
// ---------------------------------------------------------------------------------------------
|
|
1379
|
+
];
|
|
1380
|
+
case 38:
|
|
1381
|
+
_r.sent();
|
|
1382
|
+
G15_NAME = "save_field_response rejects another patient's formResponseId";
|
|
1383
|
+
return [4 /*yield*/, runScenario('G15', G15_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1384
|
+
var patientA, patientB, sessionA, bPrepared, bResponseId, answer, byId, byCode, byIdWrongCode, aResponse, ownBoth;
|
|
1385
|
+
var _a, _b, _c;
|
|
1386
|
+
return __generator(this, function (_d) {
|
|
1387
|
+
switch (_d.label) {
|
|
1388
|
+
case 0: return [4 /*yield*/, makeVictim('M1', { phone: nextPhone() })];
|
|
1389
|
+
case 1:
|
|
1390
|
+
patientA = _d.sent();
|
|
1391
|
+
return [4 /*yield*/, makeVictim('M2', { phone: nextPhone() })];
|
|
1392
|
+
case 2:
|
|
1393
|
+
patientB = _d.sent();
|
|
1394
|
+
return [4 /*yield*/, startFormOn(partialIntake.form.id, {
|
|
1395
|
+
fname: patientA.fname, lname: patientA.lname, dateOfBirth: patientA.dateOfBirth,
|
|
1396
|
+
})];
|
|
1397
|
+
case 3:
|
|
1398
|
+
sessionA = _d.sent();
|
|
1399
|
+
if (sessionA.enduserId)
|
|
1400
|
+
createdEnduserIds.push(sessionA.enduserId);
|
|
1401
|
+
if (sessionA.enduserId !== patientA.id) {
|
|
1402
|
+
record('G15', G15_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(sessionA.enduserId, " instead of matching patient A ").concat(patientA.id));
|
|
1403
|
+
return [2 /*return*/];
|
|
1404
|
+
}
|
|
1405
|
+
return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
|
|
1406
|
+
formId: partialIntake.form.id, enduserId: patientB.id,
|
|
1407
|
+
})];
|
|
1408
|
+
case 4:
|
|
1409
|
+
bPrepared = _d.sent();
|
|
1410
|
+
bResponseId = (_b = (_a = bPrepared.response) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : (_c = bPrepared.response) === null || _c === void 0 ? void 0 : _c._id;
|
|
1411
|
+
answer = {
|
|
1412
|
+
fieldId: partialIntake.emailField.id,
|
|
1413
|
+
fieldTitle: partialIntake.emailField.title,
|
|
1414
|
+
answer: { type: 'email', value: emailFor('atk-m1') },
|
|
1415
|
+
};
|
|
1416
|
+
return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, response: answer }, sessionA.authToken)];
|
|
1417
|
+
case 5:
|
|
1418
|
+
byId = _d.sent();
|
|
1419
|
+
record('G15', G15_NAME, byId.status === 403, byId.status === 403
|
|
1420
|
+
? 'HTTP 403 as expected'
|
|
1421
|
+
: "EXPLOIT: HTTP ".concat(byId.status, " \u2014 patient A wrote into patient B's in-progress response"));
|
|
1422
|
+
return [4 /*yield*/, patchAuthed('/v1/save-field-response', { accessCode: bPrepared.accessCode, response: answer }, sessionA.authToken)];
|
|
1423
|
+
case 6:
|
|
1424
|
+
byCode = _d.sent();
|
|
1425
|
+
record('G15', 'save_field_response still accepts a known accessCode', byCode.status === 200, "HTTP ".concat(byCode.status));
|
|
1426
|
+
return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, accessCode: 'not-a-real-access-code', response: answer }, sessionA.authToken)];
|
|
1427
|
+
case 7:
|
|
1428
|
+
byIdWrongCode = _d.sent();
|
|
1429
|
+
record('G15', "another patient's formResponseId + wrong accessCode is rejected", byIdWrongCode.status === 403, "HTTP ".concat(byIdWrongCode.status));
|
|
1430
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: sessionA.accessCode } })];
|
|
1431
|
+
case 8:
|
|
1432
|
+
aResponse = (_d.sent())[0];
|
|
1433
|
+
return [4 /*yield*/, patchAuthed('/v1/save-field-response', {
|
|
1434
|
+
formResponseId: aResponse === null || aResponse === void 0 ? void 0 : aResponse.id,
|
|
1435
|
+
accessCode: sessionA.accessCode,
|
|
1436
|
+
response: {
|
|
1437
|
+
fieldId: partialIntake.emailField.id,
|
|
1438
|
+
fieldTitle: partialIntake.emailField.title,
|
|
1439
|
+
answer: { type: 'email', value: emailFor('own-both') },
|
|
1440
|
+
},
|
|
1441
|
+
}, sessionA.authToken)];
|
|
1442
|
+
case 9:
|
|
1443
|
+
ownBoth = _d.sent();
|
|
1444
|
+
record('G15', 'own response with accessCode + formResponseId still succeeds (real UI shape)', ownBoth.status === 200, ownBoth.status === 200 ? 'HTTP 200' : "REGRESSION: HTTP ".concat(ownBoth.status, " \u2014 this is how the form UI calls it"));
|
|
1445
|
+
return [2 /*return*/];
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
}); })
|
|
1449
|
+
// ---------------------------------------------------------------------------------------------
|
|
1450
|
+
// G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
|
|
1451
|
+
// This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
|
|
1452
|
+
// scoped to "matched a pre-existing patient" rather than "any public session".
|
|
1453
|
+
// ---------------------------------------------------------------------------------------------
|
|
1454
|
+
];
|
|
1455
|
+
case 39:
|
|
1456
|
+
_r.sent();
|
|
1457
|
+
// ---------------------------------------------------------------------------------------------
|
|
1458
|
+
// G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
|
|
1459
|
+
// This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
|
|
1460
|
+
// scoped to "matched a pre-existing patient" rather than "any public session".
|
|
1461
|
+
// ---------------------------------------------------------------------------------------------
|
|
1462
|
+
return [4 /*yield*/, runScenario('G16', 'brand-new visitor still gets email/phone from intake answers', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1463
|
+
var submittedEmail, submittedPhone, session, created, enduser;
|
|
1464
|
+
return __generator(this, function (_a) {
|
|
1465
|
+
switch (_a.label) {
|
|
1466
|
+
case 0:
|
|
1467
|
+
submittedEmail = emailFor('lock-new-intake');
|
|
1468
|
+
submittedPhone = nextPhone();
|
|
1469
|
+
return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
1470
|
+
fname: "Lock".concat(RUN), lname: "NewIntake".concat(RUN), dateOfBirth: '05-06-1992',
|
|
1471
|
+
})];
|
|
1472
|
+
case 1:
|
|
1473
|
+
session = _a.sent();
|
|
1474
|
+
if (session.enduserId)
|
|
1475
|
+
createdEnduserIds.push(session.enduserId);
|
|
1476
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
|
|
1477
|
+
case 2:
|
|
1478
|
+
created = _a.sent();
|
|
1479
|
+
record('G16', 'new visitor starts with no contact info from the endpoint', !created.email && !created.phone, describe(created));
|
|
1480
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1481
|
+
accessCode: session.accessCode,
|
|
1482
|
+
responses: intakeAnswers(intake, submittedEmail, submittedPhone),
|
|
1483
|
+
})];
|
|
1484
|
+
case 3:
|
|
1485
|
+
_a.sent();
|
|
1486
|
+
return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
|
|
1487
|
+
case 4:
|
|
1488
|
+
enduser = (_a.sent()).enduser;
|
|
1489
|
+
record('G16', 'brand-new visitor still gets email from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
|
|
1490
|
+
record('G16', 'brand-new visitor still gets phone from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
|
|
1491
|
+
return [2 /*return*/];
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
}); })
|
|
1495
|
+
// ---------------------------------------------------------------------------------------------
|
|
1496
|
+
// G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
|
|
1497
|
+
// ---------------------------------------------------------------------------------------------
|
|
1498
|
+
];
|
|
1499
|
+
case 40:
|
|
1500
|
+
// ---------------------------------------------------------------------------------------------
|
|
1501
|
+
// G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
|
|
1502
|
+
// This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
|
|
1503
|
+
// scoped to "matched a pre-existing patient" rather than "any public session".
|
|
1504
|
+
// ---------------------------------------------------------------------------------------------
|
|
1505
|
+
_r.sent();
|
|
1506
|
+
// ---------------------------------------------------------------------------------------------
|
|
1507
|
+
// G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
|
|
1508
|
+
// ---------------------------------------------------------------------------------------------
|
|
1509
|
+
return [4 /*yield*/, runScenario('G17', 'authenticated portal enduser can still self-update via intake', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1510
|
+
var patient, newEmail, newPhone, portalSDK, prepared, enduser;
|
|
1511
|
+
return __generator(this, function (_a) {
|
|
1512
|
+
switch (_a.label) {
|
|
1513
|
+
case 0: return [4 /*yield*/, makeVictim('N1', { email: emailFor('lock-n1'), phone: nextPhone() })];
|
|
1514
|
+
case 1:
|
|
1515
|
+
patient = _a.sent();
|
|
1516
|
+
newEmail = emailFor('lock-n1-new');
|
|
1517
|
+
newPhone = nextPhone();
|
|
1518
|
+
portalSDK = new EnduserSession({ host: host, businessId: businessId });
|
|
1519
|
+
return [4 /*yield*/, authenticate_enduser_via_token(sdk, portalSDK, { id: patient.id })];
|
|
1520
|
+
case 2:
|
|
1521
|
+
_a.sent();
|
|
1522
|
+
return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
|
|
1523
|
+
formId: intake.form.id, enduserId: patient.id,
|
|
1524
|
+
})];
|
|
1525
|
+
case 3:
|
|
1526
|
+
prepared = _a.sent();
|
|
1527
|
+
return [4 /*yield*/, portalSDK.api.form_responses.submit_form_response({
|
|
1528
|
+
accessCode: prepared.accessCode,
|
|
1529
|
+
responses: intakeAnswers(intake, newEmail, newPhone),
|
|
1530
|
+
})];
|
|
1531
|
+
case 4:
|
|
1532
|
+
_a.sent();
|
|
1533
|
+
return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
|
|
1534
|
+
case 5:
|
|
1535
|
+
enduser = (_a.sent()).enduser;
|
|
1536
|
+
record('G17', 'authenticated portal intake still updates email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
|
|
1537
|
+
record('G17', 'authenticated portal intake still updates phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
|
|
1538
|
+
return [2 /*return*/];
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
}); })
|
|
1542
|
+
// ---------------------------------------------------------------------------------------------
|
|
1543
|
+
// G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
|
|
1544
|
+
// ---------------------------------------------------------------------------------------------
|
|
1545
|
+
];
|
|
1546
|
+
case 41:
|
|
1547
|
+
// ---------------------------------------------------------------------------------------------
|
|
1548
|
+
// G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
|
|
1549
|
+
// ---------------------------------------------------------------------------------------------
|
|
1550
|
+
_r.sent();
|
|
1551
|
+
// ---------------------------------------------------------------------------------------------
|
|
1552
|
+
// G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
|
|
1553
|
+
// ---------------------------------------------------------------------------------------------
|
|
1554
|
+
return [4 /*yield*/, runScenario('G18', 'staff intake submission still updates email/phone', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1555
|
+
var patient, newEmail, newPhone, prepared, enduser;
|
|
1556
|
+
return __generator(this, function (_a) {
|
|
1557
|
+
switch (_a.label) {
|
|
1558
|
+
case 0: return [4 /*yield*/, makeVictim('O1', { email: emailFor('lock-o1'), phone: nextPhone() })];
|
|
1559
|
+
case 1:
|
|
1560
|
+
patient = _a.sent();
|
|
1561
|
+
newEmail = emailFor('lock-o1-new');
|
|
1562
|
+
newPhone = nextPhone();
|
|
1563
|
+
return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
|
|
1564
|
+
formId: intake.form.id, enduserId: patient.id,
|
|
1565
|
+
})];
|
|
1566
|
+
case 2:
|
|
1567
|
+
prepared = _a.sent();
|
|
1568
|
+
return [4 /*yield*/, sdk.api.form_responses.submit_form_response({
|
|
1569
|
+
accessCode: prepared.accessCode,
|
|
1570
|
+
responses: intakeAnswers(intake, newEmail, newPhone),
|
|
1571
|
+
})];
|
|
1572
|
+
case 3:
|
|
1573
|
+
_a.sent();
|
|
1574
|
+
return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
|
|
1575
|
+
case 4:
|
|
1576
|
+
enduser = (_a.sent()).enduser;
|
|
1577
|
+
record('G18', 'staff intake submission still updates email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
|
|
1578
|
+
record('G18', 'staff intake submission still updates phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
|
|
1579
|
+
return [2 /*return*/];
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
}); })
|
|
1583
|
+
// ---------------------------------------------------------------------------------------------
|
|
1584
|
+
// G19 / G20 — Cross-patient session via a null fname/lname match.
|
|
1585
|
+
//
|
|
1586
|
+
// In both handlers' $or, `email` and `dateOfBirth` get `|| "never"` sentinels but fname/lname are
|
|
1587
|
+
// interpolated RAW. `ignoreUndefined` is not set on the Mongo client (database.ts connect options;
|
|
1588
|
+
// driver 5.7.0 defaults it to false), and findOne does not strip undefined keys — so an absent
|
|
1589
|
+
// fname serializes to `{fname: null}`, which matches every document where the field is null OR
|
|
1590
|
+
// MISSING. fname/lname have no schema initializer, so a cold-started enduser has them absent.
|
|
1591
|
+
//
|
|
1592
|
+
// Net: a request supplying ONLY dateOfBirth queries {fname: null, lname: null, dateOfBirth: X} and
|
|
1593
|
+
// can match another patient, returning a session scoped to THEIR record. The cold-start pattern is
|
|
1594
|
+
// what manufactures nameless endusers, and a later intakeField: 'dateOfBirth' question fills the DOB.
|
|
1595
|
+
// ---------------------------------------------------------------------------------------------
|
|
1596
|
+
// dateOfBirth is only string-validated and the handlers merely trim it, so a run-tokenized value is
|
|
1597
|
+
// accepted. Deliberately opaque rather than date-shaped, so match-vs-insert is unambiguous even when
|
|
1598
|
+
// this file runs inside the full suite (no reset_db between files).
|
|
1599
|
+
];
|
|
1600
|
+
case 42:
|
|
1601
|
+
// ---------------------------------------------------------------------------------------------
|
|
1602
|
+
// G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
|
|
1603
|
+
// ---------------------------------------------------------------------------------------------
|
|
1604
|
+
_r.sent();
|
|
1605
|
+
namelessDob = "01-02-1990-".concat(RUN);
|
|
1606
|
+
makeNamelessVictim = function (tag) { return __awaiter(void 0, void 0, void 0, function () {
|
|
1607
|
+
var victim, stored;
|
|
1608
|
+
return __generator(this, function (_a) {
|
|
1609
|
+
switch (_a.label) {
|
|
1610
|
+
case 0: return [4 /*yield*/, sdk.api.endusers.createOne({ dateOfBirth: "".concat(namelessDob, "-").concat(tag) })];
|
|
1611
|
+
case 1:
|
|
1612
|
+
victim = _a.sent();
|
|
1613
|
+
createdEnduserIds.push(victim.id);
|
|
1614
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(victim.id)];
|
|
1615
|
+
case 2:
|
|
1616
|
+
stored = _a.sent();
|
|
1617
|
+
return [2 /*return*/, { victim: victim, stored: stored }];
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
}); };
|
|
1621
|
+
G19_NAME = 'DOB-only request does not hijack a nameless patient (form endpoint)';
|
|
1622
|
+
return [4 /*yield*/, runScenario('G19', G19_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1623
|
+
var _a, victim, stored, session;
|
|
1624
|
+
return __generator(this, function (_b) {
|
|
1625
|
+
switch (_b.label) {
|
|
1626
|
+
case 0: return [4 /*yield*/, makeNamelessVictim('f')
|
|
1627
|
+
// Probe validity: a stored '' would not be null-matched and would make this test vacuous.
|
|
1628
|
+
];
|
|
1629
|
+
case 1:
|
|
1630
|
+
_a = _b.sent(), victim = _a.victim, stored = _a.stored;
|
|
1631
|
+
// Probe validity: a stored '' would not be null-matched and would make this test vacuous.
|
|
1632
|
+
record('G19', 'nameless victim really has absent fname/lname (probe validity)', stored.fname === undefined && stored.lname === undefined, "fname=".concat(JSON.stringify(stored.fname), " lname=").concat(JSON.stringify(stored.lname)));
|
|
1633
|
+
return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: stored.dateOfBirth })];
|
|
1634
|
+
case 2:
|
|
1635
|
+
session = _b.sent();
|
|
1636
|
+
if (session.enduserId)
|
|
1637
|
+
createdEnduserIds.push(session.enduserId);
|
|
1638
|
+
record('G19', G19_NAME, session.enduserId !== victim.id, session.enduserId !== victim.id
|
|
1639
|
+
? "inserted a new record (".concat(session.enduserId, ") instead of matching the victim")
|
|
1640
|
+
: "EXPLOIT: returned a session for the victim's record ".concat(victim.id, " from a DOB-only request"));
|
|
1641
|
+
return [2 /*return*/];
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
}); })];
|
|
1645
|
+
case 43:
|
|
1646
|
+
_r.sent();
|
|
1647
|
+
G20_NAME = 'DOB-only request does not hijack a nameless patient (booking endpoint)';
|
|
1648
|
+
return [4 /*yield*/, runScenario('G20', G20_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1649
|
+
var _a, victim, stored, dob, withDob;
|
|
1650
|
+
return __generator(this, function (_b) {
|
|
1651
|
+
switch (_b.label) {
|
|
1652
|
+
case 0: return [4 /*yield*/, makeNamelessVictim('b')];
|
|
1653
|
+
case 1:
|
|
1654
|
+
_a = _b.sent(), victim = _a.victim, stored = _a.stored;
|
|
1655
|
+
dob = stored.dateOfBirth;
|
|
1656
|
+
// The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
|
|
1657
|
+
// reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
|
|
1658
|
+
// records carrying this run-unique DOB.
|
|
1659
|
+
return [4 /*yield*/, startBooking({ dateOfBirth: dob })];
|
|
1660
|
+
case 2:
|
|
1661
|
+
// The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
|
|
1662
|
+
// reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
|
|
1663
|
+
// records carrying this run-unique DOB.
|
|
1664
|
+
_b.sent();
|
|
1665
|
+
return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { dateOfBirth: dob } })];
|
|
1666
|
+
case 3:
|
|
1667
|
+
withDob = _b.sent();
|
|
1668
|
+
withDob.forEach(function (e) { if (e.id !== victim.id)
|
|
1669
|
+
createdEnduserIds.push(e.id); });
|
|
1670
|
+
record('G20', G20_NAME, withDob.length > 1, withDob.length > 1
|
|
1671
|
+
? "inserted a new record (".concat(withDob.length, " total with this DOB) instead of matching the victim")
|
|
1672
|
+
: "EXPLOIT: matched the victim's record ".concat(victim.id, " from a DOB-only request \u2014 a booking session was issued for it"));
|
|
1673
|
+
return [2 /*return*/];
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
}); })
|
|
1677
|
+
// ---------------------------------------------------------------------------------------------
|
|
1678
|
+
// G21 — The match sentinel must never be stored as a patient's name.
|
|
1679
|
+
// Guard rail on the fix: the fallback belongs in the filter literal only, never written into
|
|
1680
|
+
// enduserInfo (which is spread into both the enduser insert and the form_responses record).
|
|
1681
|
+
// ---------------------------------------------------------------------------------------------
|
|
1682
|
+
];
|
|
1683
|
+
case 44:
|
|
1684
|
+
_r.sent();
|
|
1685
|
+
// ---------------------------------------------------------------------------------------------
|
|
1686
|
+
// G21 — The match sentinel must never be stored as a patient's name.
|
|
1687
|
+
// Guard rail on the fix: the fallback belongs in the filter literal only, never written into
|
|
1688
|
+
// enduserInfo (which is spread into both the enduser insert and the form_responses record).
|
|
1689
|
+
// ---------------------------------------------------------------------------------------------
|
|
1690
|
+
return [4 /*yield*/, runScenario('G21', 'match sentinel never leaks into stored fname/lname', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1691
|
+
var session, created, leaked, response;
|
|
1692
|
+
return __generator(this, function (_a) {
|
|
1693
|
+
switch (_a.label) {
|
|
1694
|
+
case 0: return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: "".concat(namelessDob, "-sentinel") })];
|
|
1695
|
+
case 1:
|
|
1696
|
+
session = _a.sent();
|
|
1697
|
+
if (session.enduserId)
|
|
1698
|
+
createdEnduserIds.push(session.enduserId);
|
|
1699
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
|
|
1700
|
+
case 2:
|
|
1701
|
+
created = _a.sent();
|
|
1702
|
+
leaked = function (v) { return v === '__never__' || v === 'never'; };
|
|
1703
|
+
record('G21', 'created enduser fname/lname are not the sentinel', !leaked(created.fname) && !leaked(created.lname), "fname=".concat(JSON.stringify(created.fname), " lname=").concat(JSON.stringify(created.lname)));
|
|
1704
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
|
|
1705
|
+
case 3:
|
|
1706
|
+
response = (_a.sent())[0];
|
|
1707
|
+
record('G21', 'form_responses fname/lname are not the sentinel', !leaked(response === null || response === void 0 ? void 0 : response.fname) && !leaked(response === null || response === void 0 ? void 0 : response.lname), "fname=".concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.fname), " lname=").concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.lname)));
|
|
1708
|
+
return [2 /*return*/];
|
|
1709
|
+
}
|
|
1710
|
+
});
|
|
1711
|
+
}); })
|
|
1712
|
+
// ---------------------------------------------------------------------------------------------
|
|
1713
|
+
// G22 — The cold-start pattern: a public form started with NO intake info, which just creates an
|
|
1714
|
+
// Enduser with an ID. Intake answers (including the authentication fields email/phone) must still
|
|
1715
|
+
// populate that fresh record — the lock is scoped to "matched a pre-existing patient", not to
|
|
1716
|
+
// "any public session", precisely so this keeps working.
|
|
1717
|
+
// ---------------------------------------------------------------------------------------------
|
|
1718
|
+
];
|
|
1719
|
+
case 45:
|
|
1720
|
+
// ---------------------------------------------------------------------------------------------
|
|
1721
|
+
// G21 — The match sentinel must never be stored as a patient's name.
|
|
1722
|
+
// Guard rail on the fix: the fallback belongs in the filter literal only, never written into
|
|
1723
|
+
// enduserInfo (which is spread into both the enduser insert and the form_responses record).
|
|
1724
|
+
// ---------------------------------------------------------------------------------------------
|
|
1725
|
+
_r.sent();
|
|
1726
|
+
coldStartCase = function (label, args) { return __awaiter(void 0, void 0, void 0, function () {
|
|
1727
|
+
var group, submittedEmail, submittedPhone, session, created, enduser;
|
|
1728
|
+
return __generator(this, function (_a) {
|
|
1729
|
+
switch (_a.label) {
|
|
1730
|
+
case 0:
|
|
1731
|
+
group = 'G22';
|
|
1732
|
+
submittedEmail = emailFor("cold-".concat(label));
|
|
1733
|
+
submittedPhone = nextPhone();
|
|
1734
|
+
return [4 /*yield*/, startFormOn(intake.form.id, args)];
|
|
1735
|
+
case 1:
|
|
1736
|
+
session = _a.sent();
|
|
1737
|
+
if (session.enduserId)
|
|
1738
|
+
createdEnduserIds.push(session.enduserId);
|
|
1739
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
|
|
1740
|
+
case 2:
|
|
1741
|
+
created = _a.sent();
|
|
1742
|
+
record(group, "".concat(label, ": cold start creates a bare enduser"), !created.email && !created.phone && !!session.enduserId, describe(created));
|
|
1743
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
1744
|
+
accessCode: session.accessCode,
|
|
1745
|
+
responses: intakeAnswers(intake, submittedEmail, submittedPhone),
|
|
1746
|
+
})];
|
|
1747
|
+
case 3:
|
|
1748
|
+
_a.sent();
|
|
1749
|
+
return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
|
|
1750
|
+
case 4:
|
|
1751
|
+
enduser = (_a.sent()).enduser;
|
|
1752
|
+
record(group, "".concat(label, ": intake email (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
|
|
1753
|
+
record(group, "".concat(label, ": intake phone (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
|
|
1754
|
+
return [2 /*return*/];
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
}); };
|
|
1758
|
+
// The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
|
|
1759
|
+
return [4 /*yield*/, runScenario('G22', 'cold start with skipMatch (real webapp shape)', function () { return coldStartCase('skipMatch', { skipMatch: true }); })
|
|
1760
|
+
// And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
|
|
1761
|
+
// (unstorable) and the name branch needs dateOfBirth === "never" literally.
|
|
1762
|
+
];
|
|
1763
|
+
case 46:
|
|
1764
|
+
// The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
|
|
1765
|
+
_r.sent();
|
|
1766
|
+
// And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
|
|
1767
|
+
// (unstorable) and the name branch needs dateOfBirth === "never" literally.
|
|
1768
|
+
return [4 /*yield*/, runScenario('G22', 'cold start without skipMatch', function () { return coldStartCase('no-skipMatch', {}); })
|
|
1769
|
+
// ---------------------------------------------------------------------------------------------
|
|
1770
|
+
// G23 — businessId must be required on session_for_public_form.
|
|
1771
|
+
//
|
|
1772
|
+
// addPublicEndpoint overwrites the schema's `required: true` for businessId with
|
|
1773
|
+
// mongoIdStringOptional (routing.ts, and its own "todo: make businessId required"), and TypeScript
|
|
1774
|
+
// cannot catch it because CustomAction declares businessId as a non-optional string.
|
|
1775
|
+
//
|
|
1776
|
+
// A falsy businessId then leaves restrictAccess as the IDENTITY function in
|
|
1777
|
+
// buildAllQueries/buildOrganizationWideQueries (database.ts): the tenant clause is not appended at
|
|
1778
|
+
// all, so every query in the handler runs across ALL tenants. It fails OPEN, which is worse than
|
|
1779
|
+
// matching nothing. login_enduser signs the JWT from enduser.businessId, not from the request, so
|
|
1780
|
+
// the returned token is a fully valid session on whatever record matched.
|
|
1781
|
+
//
|
|
1782
|
+
// session_for_public_appointment_booking already guards this (its own comment says the schema
|
|
1783
|
+
// cannot enforce it); this endpoint did not. Note the sentinel fix in G19/G20 does NOT cover this:
|
|
1784
|
+
// sentinels constrain which record shapes the filter can match, not which tenants it searches.
|
|
1785
|
+
// ---------------------------------------------------------------------------------------------
|
|
1786
|
+
];
|
|
1787
|
+
case 47:
|
|
1788
|
+
// And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
|
|
1789
|
+
// (unstorable) and the name branch needs dateOfBirth === "never" literally.
|
|
1790
|
+
_r.sent();
|
|
1791
|
+
G23_NAME = 'session_for_public_form requires businessId';
|
|
1792
|
+
return [4 /*yield*/, runScenario('G23', G23_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1793
|
+
var victim, omitted, blocked, wrongBiz, badBiz;
|
|
1794
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
1795
|
+
return __generator(this, function (_m) {
|
|
1796
|
+
switch (_m.label) {
|
|
1797
|
+
case 0: return [4 /*yield*/, makeVictim('P1', { email: emailFor('lock-bizid'), phone: nextPhone() })
|
|
1798
|
+
// (a) THE ISSUE. Omit businessId entirely and name the patient directly via enduserId, which
|
|
1799
|
+
// reaches an untenanted findById — no name/DOB guessing required. Pre-fix this hands back a
|
|
1800
|
+
// working session for that patient; post-fix it must 400 before touching the database.
|
|
1801
|
+
];
|
|
1802
|
+
case 1:
|
|
1803
|
+
victim = _m.sent();
|
|
1804
|
+
return [4 /*yield*/, post('/v1/session-for-public-form', {
|
|
1805
|
+
formId: intake.form.id,
|
|
1806
|
+
enduserId: victim.id,
|
|
1807
|
+
})];
|
|
1808
|
+
case 2:
|
|
1809
|
+
omitted = _m.sent();
|
|
1810
|
+
if ((_a = omitted.data) === null || _a === void 0 ? void 0 : _a.enduserId)
|
|
1811
|
+
createdEnduserIds.push(omitted.data.enduserId);
|
|
1812
|
+
blocked = omitted.status === 400 && ((_b = omitted.data) === null || _b === void 0 ? void 0 : _b.message) === 'businessId is required';
|
|
1813
|
+
record('G23', "".concat(G23_NAME, " \u2014 omitted businessId is rejected"), blocked, blocked
|
|
1814
|
+
? 'HTTP 400 businessId is required'
|
|
1815
|
+
: "EXPLOIT: HTTP ".concat(omitted.status, " \u2014 got enduserId=").concat((_c = omitted.data) === null || _c === void 0 ? void 0 : _c.enduserId, " authToken=").concat(!!((_d = omitted.data) === null || _d === void 0 ? void 0 : _d.authToken), " for victim ").concat(victim.id, " with NO businessId, i.e. tenant filtering was disabled entirely"));
|
|
1816
|
+
return [4 /*yield*/, post('/v1/session-for-public-form', {
|
|
1817
|
+
formId: intake.form.id,
|
|
1818
|
+
enduserId: victim.id,
|
|
1819
|
+
businessId: '000000000000000000000001',
|
|
1820
|
+
})];
|
|
1821
|
+
case 3:
|
|
1822
|
+
wrongBiz = _m.sent();
|
|
1823
|
+
record('G23', "".concat(G23_NAME, " \u2014 a wrong businessId cannot reach the patient"), wrongBiz.status !== 200 && ((_e = wrongBiz.data) === null || _e === void 0 ? void 0 : _e.enduserId) !== victim.id, "HTTP ".concat(wrongBiz.status, " ").concat(JSON.stringify((_j = (_g = (_f = wrongBiz.data) === null || _f === void 0 ? void 0 : _f.message) !== null && _g !== void 0 ? _g : (_h = wrongBiz.data) === null || _h === void 0 ? void 0 : _h.enduserId) !== null && _j !== void 0 ? _j : '')));
|
|
1824
|
+
return [4 /*yield*/, post('/v1/session-for-public-form', {
|
|
1825
|
+
formId: intake.form.id,
|
|
1826
|
+
enduserId: victim.id,
|
|
1827
|
+
businessId: 'not-a-mongo-id',
|
|
1828
|
+
})];
|
|
1829
|
+
case 4:
|
|
1830
|
+
badBiz = _m.sent();
|
|
1831
|
+
record('G23', "".concat(G23_NAME, " \u2014 a malformed businessId is rejected"), badBiz.status === 400, "HTTP ".concat(badBiz.status, " ").concat(JSON.stringify((_l = (_k = badBiz.data) === null || _k === void 0 ? void 0 : _k.message) !== null && _l !== void 0 ? _l : '')));
|
|
1832
|
+
return [2 /*return*/];
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}); })
|
|
1836
|
+
// ---------------------------------------------------------------------------------------------
|
|
1837
|
+
// G24 / G25 — The token prepare_form_response mints drops `fromPublicSession`.
|
|
1838
|
+
//
|
|
1839
|
+
// The lock in prepare_form_response keys off session.fromPublicSession, but the token it mints
|
|
1840
|
+
// preserved the session's confinement WITHOUT that flag and handed it back in fullURL (?token=...).
|
|
1841
|
+
// Since prepare_form_response is itself in the 'public-form' session scope, a caller can hop twice:
|
|
1842
|
+
// the first hop's response is locked, the second hop's is not — because by then the session no longer
|
|
1843
|
+
// looks like a public one. Submitting an intakeField: 'email' answer against that second response
|
|
1844
|
+
// then repoints the victim's login channel.
|
|
1845
|
+
//
|
|
1846
|
+
// The same flag also gates PHI on refresh_session (G25): when it is falsy the handler returns
|
|
1847
|
+
// redact_field(enduser, 'hashedPassword') — the whole record — instead of just assignedTo.
|
|
1848
|
+
// ---------------------------------------------------------------------------------------------
|
|
1849
|
+
];
|
|
1850
|
+
case 48:
|
|
1851
|
+
_r.sent();
|
|
1852
|
+
chainForVictim = function (tag, victim) { return __awaiter(void 0, void 0, void 0, function () {
|
|
1853
|
+
var t1, hop1;
|
|
1854
|
+
return __generator(this, function (_a) {
|
|
1855
|
+
switch (_a.label) {
|
|
1856
|
+
case 0: return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
1857
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
1858
|
+
})];
|
|
1859
|
+
case 1:
|
|
1860
|
+
t1 = _a.sent();
|
|
1861
|
+
if (t1.enduserId)
|
|
1862
|
+
createdEnduserIds.push(t1.enduserId);
|
|
1863
|
+
if (t1.enduserId !== victim.id)
|
|
1864
|
+
return [2 /*return*/, { matched: false, t1: t1, t2: undefined, hop1: undefined }];
|
|
1865
|
+
return [4 /*yield*/, asEnduser(t1.authToken).api.form_responses.prepare_form_response({
|
|
1866
|
+
formId: pivotIntake.form.id, enduserId: victim.id,
|
|
1867
|
+
})];
|
|
1868
|
+
case 2:
|
|
1869
|
+
hop1 = _a.sent();
|
|
1870
|
+
return [2 /*return*/, { matched: true, t1: t1, hop1: hop1, t2: tokenFromURL(hop1 === null || hop1 === void 0 ? void 0 : hop1.fullURL) }];
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1873
|
+
}); };
|
|
1874
|
+
G24_NAME = 'chained prepare_form_response session stays locked';
|
|
1875
|
+
return [4 /*yield*/, runScenario('G24', G24_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1876
|
+
var victimEmail, victim, attackerEmail, _a, matched, t2, hop2, hop2Response, enduser;
|
|
1877
|
+
return __generator(this, function (_b) {
|
|
1878
|
+
switch (_b.label) {
|
|
1879
|
+
case 0:
|
|
1880
|
+
victimEmail = emailFor('lock-q1');
|
|
1881
|
+
return [4 /*yield*/, makeVictim('Q1', { email: victimEmail, phone: nextPhone() })];
|
|
1882
|
+
case 1:
|
|
1883
|
+
victim = _b.sent();
|
|
1884
|
+
attackerEmail = emailFor('atk-q1');
|
|
1885
|
+
return [4 /*yield*/, chainForVictim('Q1', victim)];
|
|
1886
|
+
case 2:
|
|
1887
|
+
_a = _b.sent(), matched = _a.matched, t2 = _a.t2;
|
|
1888
|
+
if (!matched) {
|
|
1889
|
+
record('G24', G24_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
|
|
1890
|
+
return [2 /*return*/];
|
|
1891
|
+
}
|
|
1892
|
+
record('G24', 'chained token is returned in fullURL (probe validity)', !!t2, "token extracted=".concat(!!t2));
|
|
1893
|
+
if (!t2)
|
|
1894
|
+
return [2 /*return*/];
|
|
1895
|
+
return [4 /*yield*/, asEnduser(t2).api.form_responses.prepare_form_response({
|
|
1896
|
+
formId: pivotIntake.form.id, enduserId: victim.id,
|
|
1897
|
+
})];
|
|
1898
|
+
case 3:
|
|
1899
|
+
hop2 = _b.sent();
|
|
1900
|
+
record('G24', 'second-hop prepare succeeds (probe validity)', !!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode), "accessCode=".concat(!!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode)));
|
|
1901
|
+
if (!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode))
|
|
1902
|
+
return [2 /*return*/];
|
|
1903
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: hop2.accessCode } })];
|
|
1904
|
+
case 4:
|
|
1905
|
+
hop2Response = (_b.sent())[0];
|
|
1906
|
+
record('G24', "".concat(G24_NAME, " \u2014 second-hop response carries the lock"), (hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled) === true, "authFieldIntakeDisabled=".concat(JSON.stringify(hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled)));
|
|
1907
|
+
// The assertion that actually matters: does the victim's login channel move?
|
|
1908
|
+
return [4 /*yield*/, asEnduser(t2).api.form_responses.submit_form_response({
|
|
1909
|
+
accessCode: hop2.accessCode,
|
|
1910
|
+
responses: intakeAnswers(pivotIntake, attackerEmail, nextPhone()),
|
|
1911
|
+
})];
|
|
1912
|
+
case 5:
|
|
1913
|
+
// The assertion that actually matters: does the victim's login channel move?
|
|
1914
|
+
_b.sent();
|
|
1915
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
|
|
1916
|
+
case 6:
|
|
1917
|
+
enduser = (_b.sent()).enduser;
|
|
1918
|
+
record('G24', "".concat(G24_NAME, " \u2014 victim email unchanged after chained submit"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
|
|
1919
|
+
? "email still ".concat(victimEmail)
|
|
1920
|
+
: "EXPLOIT: chained session wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " via an intake answer"));
|
|
1921
|
+
return [2 /*return*/];
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
}); })];
|
|
1925
|
+
case 49:
|
|
1926
|
+
_r.sent();
|
|
1927
|
+
G25_NAME = 'chained session does not expose the enduser record on refresh';
|
|
1928
|
+
return [4 /*yield*/, runScenario('G25', G25_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1929
|
+
var victim, _a, matched, t1, t2, PHI_KEYS, leakedIn, base, chained, leaked;
|
|
1930
|
+
return __generator(this, function (_b) {
|
|
1931
|
+
switch (_b.label) {
|
|
1932
|
+
case 0: return [4 /*yield*/, makeVictim('R1', { email: emailFor('lock-r1'), phone: nextPhone() })];
|
|
1933
|
+
case 1:
|
|
1934
|
+
victim = _b.sent();
|
|
1935
|
+
return [4 /*yield*/, chainForVictim('R1', victim)];
|
|
1936
|
+
case 2:
|
|
1937
|
+
_a = _b.sent(), matched = _a.matched, t1 = _a.t1, t2 = _a.t2;
|
|
1938
|
+
if (!matched) {
|
|
1939
|
+
record('G25', G25_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
|
|
1940
|
+
return [2 /*return*/];
|
|
1941
|
+
}
|
|
1942
|
+
PHI_KEYS = ['email', 'phone', 'dateOfBirth', 'fname', 'lname'];
|
|
1943
|
+
leakedIn = function (body) { return PHI_KEYS.filter(function (k) { var _a; return ((_a = body === null || body === void 0 ? void 0 : body.enduser) !== null && _a !== void 0 ? _a : {})[k] !== undefined; }); };
|
|
1944
|
+
return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t1.authToken)];
|
|
1945
|
+
case 3:
|
|
1946
|
+
base = _b.sent();
|
|
1947
|
+
record('G25', 'baseline: original public token withholds the record (control)', base.status === 200 && leakedIn(base.data).length === 0, "HTTP ".concat(base.status, " leaked=[").concat(leakedIn(base.data).join(', '), "]"));
|
|
1948
|
+
if (!t2) {
|
|
1949
|
+
record('G25', G25_NAME, false, 'probe invalid — no chained token');
|
|
1950
|
+
return [2 /*return*/];
|
|
1951
|
+
}
|
|
1952
|
+
return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t2)];
|
|
1953
|
+
case 4:
|
|
1954
|
+
chained = _b.sent();
|
|
1955
|
+
leaked = leakedIn(chained.data);
|
|
1956
|
+
record('G25', G25_NAME, chained.status === 200 && leaked.length === 0, leaked.length === 0
|
|
1957
|
+
? "HTTP ".concat(chained.status, ", no PHI fields returned")
|
|
1958
|
+
: "EXPLOIT: refresh under the chained token returned [".concat(leaked.join(', '), "] for victim ").concat(victim.id));
|
|
1959
|
+
return [2 /*return*/];
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
}); })
|
|
1963
|
+
// ---------------------------------------------------------------------------------------------
|
|
1964
|
+
// G26 — Auto-merge re-points the response after the lock was computed.
|
|
1965
|
+
//
|
|
1966
|
+
// authFieldIntakeDisabled is a snapshot taken at session start (`!!existing`). With skipMatch it is
|
|
1967
|
+
// correctly false, because a fresh record really was inserted. But on submission
|
|
1968
|
+
// resolveAutoMergeBeforeSubmission repoints the response at a PRE-EXISTING patient and persists only
|
|
1969
|
+
// enduserId — so the intake write later reads a stale `false` and lands email/phone on the victim.
|
|
1970
|
+
// The merge matches on { fname, lname, dateOfBirth }, so the attacker never needs to know the
|
|
1971
|
+
// victim's email or phone.
|
|
1972
|
+
//
|
|
1973
|
+
// performAutoMerge copies no demographic fields itself, so the intake write is the only channel —
|
|
1974
|
+
// which is why the custom-field assertion below is essential: it proves the write RAN, so "email
|
|
1975
|
+
// unchanged" cannot pass just because nothing happened.
|
|
1976
|
+
// ---------------------------------------------------------------------------------------------
|
|
1977
|
+
];
|
|
1978
|
+
case 50:
|
|
1979
|
+
_r.sent();
|
|
1980
|
+
G26_NAME = 'auto-merge does not let an intake answer repoint the merged patient';
|
|
1981
|
+
return [4 /*yield*/, runScenario('G26', G26_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1982
|
+
var victimEmail, victimPhone, victimDob, victim, attackerEmail, session, sourceId, f, after, merged, customLanded;
|
|
1983
|
+
var _a, _b, _c;
|
|
1984
|
+
return __generator(this, function (_d) {
|
|
1985
|
+
switch (_d.label) {
|
|
1986
|
+
case 0:
|
|
1987
|
+
victimEmail = emailFor('lock-s1');
|
|
1988
|
+
victimPhone = nextPhone();
|
|
1989
|
+
victimDob = '07-08-1988';
|
|
1990
|
+
return [4 /*yield*/, sdk.api.endusers.createOne({
|
|
1991
|
+
fname: "Lock".concat(RUN),
|
|
1992
|
+
lname: "Merge".concat(RUN, "S1"),
|
|
1993
|
+
dateOfBirth: victimDob,
|
|
1994
|
+
email: victimEmail,
|
|
1995
|
+
phone: victimPhone,
|
|
1996
|
+
})];
|
|
1997
|
+
case 1:
|
|
1998
|
+
victim = _d.sent();
|
|
1999
|
+
createdEnduserIds.push(victim.id);
|
|
2000
|
+
attackerEmail = emailFor('atk-s1');
|
|
2001
|
+
return [4 /*yield*/, startFormOn(autoMergeIntake.form.id, { skipMatch: true })];
|
|
2002
|
+
case 2:
|
|
2003
|
+
session = _d.sent();
|
|
2004
|
+
if (session.enduserId)
|
|
2005
|
+
createdEnduserIds.push(session.enduserId);
|
|
2006
|
+
sourceId = session.enduserId;
|
|
2007
|
+
f = autoMergeIntake;
|
|
2008
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
2009
|
+
accessCode: session.accessCode,
|
|
2010
|
+
responses: [
|
|
2011
|
+
{ fieldId: f.fname.id, fieldTitle: f.fname.title, answer: { type: 'string', value: victim.fname } },
|
|
2012
|
+
{ fieldId: f.lname.id, fieldTitle: f.lname.title, answer: { type: 'string', value: victim.lname } },
|
|
2013
|
+
{ fieldId: f.dateOfBirth.id, fieldTitle: f.dateOfBirth.title, answer: { type: 'dateString', value: victimDob } },
|
|
2014
|
+
{ fieldId: f.email.id, fieldTitle: f.email.title, answer: { type: 'email', value: attackerEmail } },
|
|
2015
|
+
{ fieldId: f.custom.id, fieldTitle: f.custom.title, answer: { type: 'string', value: 'merged-ok' } },
|
|
2016
|
+
],
|
|
2017
|
+
})];
|
|
2018
|
+
case 3:
|
|
2019
|
+
_d.sent();
|
|
2020
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
|
|
2021
|
+
case 4:
|
|
2022
|
+
after = (_d.sent()).enduser;
|
|
2023
|
+
merged = ((_a = after === null || after === void 0 ? void 0 : after.mergedIds) !== null && _a !== void 0 ? _a : []).includes(sourceId);
|
|
2024
|
+
record('G26', 'auto-merge actually resolved to the victim (probe validity)', merged, "mergedIds=".concat(JSON.stringify(after === null || after === void 0 ? void 0 : after.mergedIds), " source=").concat(sourceId));
|
|
2025
|
+
customLanded = ((_b = after === null || after === void 0 ? void 0 : after.fields) === null || _b === void 0 ? void 0 : _b[MERGE_CUSTOM_KEY]) === 'merged-ok';
|
|
2026
|
+
record('G26', 'non-auth intake fields still reconcile onto the merged patient (anti-vacuity)', customLanded, "fields.".concat(MERGE_CUSTOM_KEY, "=").concat(JSON.stringify((_c = after === null || after === void 0 ? void 0 : after.fields) === null || _c === void 0 ? void 0 : _c[MERGE_CUSTOM_KEY])));
|
|
2027
|
+
record('G26', "".concat(G26_NAME, " \u2014 email"), (after === null || after === void 0 ? void 0 : after.email) === victimEmail, (after === null || after === void 0 ? void 0 : after.email) === victimEmail
|
|
2028
|
+
? "email still ".concat(victimEmail)
|
|
2029
|
+
: "EXPLOIT: auto-merge intake write set ".concat(after === null || after === void 0 ? void 0 : after.email, " on the merged-into victim"));
|
|
2030
|
+
record('G26', "".concat(G26_NAME, " \u2014 phone"), (after === null || after === void 0 ? void 0 : after.phone) === victimPhone, (after === null || after === void 0 ? void 0 : after.phone) === victimPhone ? "phone still ".concat(victimPhone) : "EXPLOIT: phone became ".concat(after === null || after === void 0 ? void 0 : after.phone));
|
|
2031
|
+
return [2 /*return*/];
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
}); })
|
|
2035
|
+
// ---------------------------------------------------------------------------------------------
|
|
2036
|
+
// G27 — The STAFF merge re-points responses too, and must also apply the lock.
|
|
2037
|
+
//
|
|
2038
|
+
// Same shape as G26 but through endusers.merge rather than autoMergeOnSubmission: the response is
|
|
2039
|
+
// created by a skipMatch public session (so the flag is correctly false — a fresh record really was
|
|
2040
|
+
// inserted), then staff resolve that ephemeral duplicate into a real patient. The merge loop copies
|
|
2041
|
+
// enduserId onto every model that has one, so without the fix the response ends up attached to a
|
|
2042
|
+
// patient who pre-existed it while still carrying a stale `false`.
|
|
2043
|
+
//
|
|
2044
|
+
// Note the source enduser is DELETED by the merge, which kills the original public token — so the
|
|
2045
|
+
// stored flag is the load-bearing assertion here, not a follow-up submit.
|
|
2046
|
+
// ---------------------------------------------------------------------------------------------
|
|
2047
|
+
];
|
|
2048
|
+
case 51:
|
|
2049
|
+
_r.sent();
|
|
2050
|
+
G27_NAME = 'staff merge applies the lock when it repoints a response';
|
|
2051
|
+
return [4 /*yield*/, runScenario('G27', G27_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2052
|
+
var victimEmail, victim, session, beforeMerge, afterMerge, repointed, stillVictim;
|
|
2053
|
+
return __generator(this, function (_a) {
|
|
2054
|
+
switch (_a.label) {
|
|
2055
|
+
case 0:
|
|
2056
|
+
victimEmail = emailFor('lock-t1');
|
|
2057
|
+
return [4 /*yield*/, makeVictim('T1', { email: victimEmail, phone: nextPhone() })];
|
|
2058
|
+
case 1:
|
|
2059
|
+
victim = _a.sent();
|
|
2060
|
+
return [4 /*yield*/, startFormOn(intake.form.id, { skipMatch: true })];
|
|
2061
|
+
case 2:
|
|
2062
|
+
session = _a.sent();
|
|
2063
|
+
if (session.enduserId)
|
|
2064
|
+
createdEnduserIds.push(session.enduserId);
|
|
2065
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
|
|
2066
|
+
// Probe validity: if this were already true the test could not distinguish the fix from the
|
|
2067
|
+
// pre-existing state.
|
|
2068
|
+
];
|
|
2069
|
+
case 3:
|
|
2070
|
+
beforeMerge = (_a.sent())[0];
|
|
2071
|
+
// Probe validity: if this were already true the test could not distinguish the fix from the
|
|
2072
|
+
// pre-existing state.
|
|
2073
|
+
record('G27', 'skipMatch response starts unlocked (probe validity)', (beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled) === false, "authFieldIntakeDisabled=".concat(JSON.stringify(beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled)));
|
|
2074
|
+
return [4 /*yield*/, sdk.api.endusers.merge({
|
|
2075
|
+
sourceEnduserId: session.enduserId,
|
|
2076
|
+
destinationEnduserId: victim.id,
|
|
2077
|
+
})];
|
|
2078
|
+
case 4:
|
|
2079
|
+
_a.sent();
|
|
2080
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
|
|
2081
|
+
case 5:
|
|
2082
|
+
afterMerge = (_a.sent())[0];
|
|
2083
|
+
repointed = (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId) === victim.id;
|
|
2084
|
+
record('G27', 'merge repointed the response at the pre-existing patient (probe validity)', repointed, "enduserId=".concat(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId, " expected=").concat(victim.id));
|
|
2085
|
+
record('G27', G27_NAME, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true
|
|
2086
|
+
? 'authFieldIntakeDisabled=true'
|
|
2087
|
+
: "EXPLOIT: response now points at pre-existing patient ".concat(victim.id, " with ")
|
|
2088
|
+
+ "authFieldIntakeDisabled=".concat(JSON.stringify(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled), " \u2014 ")
|
|
2089
|
+
+ "an intake email/phone answer would repoint their login channel");
|
|
2090
|
+
return [4 /*yield*/, sdk.api.endusers.getOne(victim.id).catch(function () { return null; })];
|
|
2091
|
+
case 6:
|
|
2092
|
+
stillVictim = _a.sent();
|
|
2093
|
+
record('G27', 'merge did not itself move the victim email', (stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) === victimEmail, "email=".concat((stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) || '(unset)', " expected=").concat(victimEmail));
|
|
2094
|
+
return [2 /*return*/];
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
}); })
|
|
2098
|
+
// ---------------------------------------------------------------------------------------------
|
|
2099
|
+
// G28 — SCOPE BOUNDARY for the Stripe intake grace.
|
|
2100
|
+
//
|
|
2101
|
+
// The formsort / form-ingestion webhook re-opens a BLANK email/phone for one hour after the Stripe
|
|
2102
|
+
// integration creates a contact (enduser_is_provisional_stripe_record, exercised by G12/R12-R15 in
|
|
2103
|
+
// formsort_webhook_auth_field_lock.test.ts). That endpoint is API-key authenticated. THIS path is
|
|
2104
|
+
// anonymous, and its match only needs a name + DOB or an email, so the same exemption here would
|
|
2105
|
+
// hand an attacker an OTP login channel on a patient who just paid. It must not apply.
|
|
2106
|
+
//
|
|
2107
|
+
// This gate exists because the tempting refactor — moving the grace down into
|
|
2108
|
+
// save_enduser_updates_for_form_response, the single chokepoint — would silently extend it to every
|
|
2109
|
+
// caller including this one, with no other test noticing.
|
|
2110
|
+
// ---------------------------------------------------------------------------------------------
|
|
2111
|
+
];
|
|
2112
|
+
case 52:
|
|
2113
|
+
_r.sent();
|
|
2114
|
+
G28_NAME = 'a fresh Stripe-created patient gets no grace on the public form path';
|
|
2115
|
+
return [4 /*yield*/, runScenario('G28', G28_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2116
|
+
var victimEmail, victim, attackerEmail, attackerPhone, session, enduser;
|
|
2117
|
+
return __generator(this, function (_a) {
|
|
2118
|
+
switch (_a.label) {
|
|
2119
|
+
case 0:
|
|
2120
|
+
victimEmail = emailFor('lock-stripe');
|
|
2121
|
+
return [4 /*yield*/, makeVictim('Stripe', {
|
|
2122
|
+
email: victimEmail,
|
|
2123
|
+
source: 'Stripe',
|
|
2124
|
+
stripeCustomerId: "cus_".concat(RUN, "g28"),
|
|
2125
|
+
})];
|
|
2126
|
+
case 1:
|
|
2127
|
+
victim = _a.sent();
|
|
2128
|
+
attackerEmail = emailFor('atk-stripe');
|
|
2129
|
+
attackerPhone = nextPhone();
|
|
2130
|
+
return [4 /*yield*/, startFormOn(intake.form.id, {
|
|
2131
|
+
fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
|
|
2132
|
+
})];
|
|
2133
|
+
case 2:
|
|
2134
|
+
session = _a.sent();
|
|
2135
|
+
if (session.enduserId)
|
|
2136
|
+
createdEnduserIds.push(session.enduserId);
|
|
2137
|
+
if (session.enduserId !== victim.id) {
|
|
2138
|
+
record('G28', G28_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
|
|
2139
|
+
return [2 /*return*/];
|
|
2140
|
+
}
|
|
2141
|
+
return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
|
|
2142
|
+
accessCode: session.accessCode,
|
|
2143
|
+
responses: intakeAnswers(intake, attackerEmail, attackerPhone),
|
|
2144
|
+
})];
|
|
2145
|
+
case 3:
|
|
2146
|
+
_a.sent();
|
|
2147
|
+
return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)
|
|
2148
|
+
// phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
|
|
2149
|
+
];
|
|
2150
|
+
case 4:
|
|
2151
|
+
enduser = (_a.sent()).enduser;
|
|
2152
|
+
// phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
|
|
2153
|
+
record('G28', "".concat(G28_NAME, " \u2014 phone"), !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), (enduser === null || enduser === void 0 ? void 0 : enduser.phone)
|
|
2154
|
+
? "EXPLOIT: intake answer wrote phone ".concat(enduser.phone, " on a Stripe-created patient (was unset)")
|
|
2155
|
+
: 'phone still (unset)');
|
|
2156
|
+
record('G28', "".concat(G28_NAME, " \u2014 email"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
|
|
2157
|
+
? "email still ".concat(victimEmail)
|
|
2158
|
+
: "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail, ")"));
|
|
2159
|
+
return [2 /*return*/];
|
|
2160
|
+
}
|
|
2161
|
+
});
|
|
2162
|
+
}); })
|
|
2163
|
+
// ---------------------------------------------------------------------------------------------
|
|
2164
|
+
// Report
|
|
2165
|
+
// ---------------------------------------------------------------------------------------------
|
|
2166
|
+
];
|
|
2167
|
+
case 53:
|
|
2168
|
+
_r.sent();
|
|
2169
|
+
failures = results.filter(function (r) { return !r.ok; });
|
|
2170
|
+
skipped = results.filter(function (r) { return !!r.skipped; });
|
|
2171
|
+
console.log('');
|
|
2172
|
+
console.log(' ┌─ Public endpoint auth-field lock — summary ────────────────────────────');
|
|
2173
|
+
for (_d = 0, results_1 = results; _d < results_1.length; _d++) {
|
|
2174
|
+
r = results_1[_d];
|
|
2175
|
+
status_1 = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL';
|
|
2176
|
+
console.log(" \u2502 ".concat(status_1, " ").concat(r.group.padEnd(3), " ").concat(r.name).concat(r.detail ? " \u2014 ".concat(r.detail) : ''));
|
|
2177
|
+
}
|
|
2178
|
+
console.log(" \u2514\u2500 ".concat(results.length - failures.length, "/").concat(results.length, " passed, ").concat(skipped.length, " skipped, ").concat(failures.length, " failed"));
|
|
2179
|
+
console.log('');
|
|
2180
|
+
_e = 0, createdEventIds_1 = createdEventIds;
|
|
2181
|
+
_r.label = 54;
|
|
2182
|
+
case 54:
|
|
2183
|
+
if (!(_e < createdEventIds_1.length)) return [3 /*break*/, 57];
|
|
2184
|
+
id = createdEventIds_1[_e];
|
|
2185
|
+
return [4 /*yield*/, sdk.api.calendar_events.deleteOne(id).catch(function () { return null; })];
|
|
2186
|
+
case 55:
|
|
2187
|
+
_r.sent();
|
|
2188
|
+
_r.label = 56;
|
|
2189
|
+
case 56:
|
|
2190
|
+
_e++;
|
|
2191
|
+
return [3 /*break*/, 54];
|
|
2192
|
+
case 57:
|
|
2193
|
+
_f = 0, createdFormIds_1 = createdFormIds;
|
|
2194
|
+
_r.label = 58;
|
|
2195
|
+
case 58:
|
|
2196
|
+
if (!(_f < createdFormIds_1.length)) return [3 /*break*/, 64];
|
|
2197
|
+
formId = createdFormIds_1[_f];
|
|
2198
|
+
return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { formId: formId } }).catch(function () { return []; })];
|
|
2199
|
+
case 59:
|
|
2200
|
+
responses = _r.sent();
|
|
2201
|
+
_g = 0, responses_1 = responses;
|
|
2202
|
+
_r.label = 60;
|
|
2203
|
+
case 60:
|
|
2204
|
+
if (!(_g < responses_1.length)) return [3 /*break*/, 63];
|
|
2205
|
+
response = responses_1[_g];
|
|
2206
|
+
return [4 /*yield*/, sdk.api.form_responses.deleteOne(response.id).catch(function () { return null; })];
|
|
2207
|
+
case 61:
|
|
2208
|
+
_r.sent();
|
|
2209
|
+
_r.label = 62;
|
|
2210
|
+
case 62:
|
|
2211
|
+
_g++;
|
|
2212
|
+
return [3 /*break*/, 60];
|
|
2213
|
+
case 63:
|
|
2214
|
+
_f++;
|
|
2215
|
+
return [3 /*break*/, 58];
|
|
2216
|
+
case 64:
|
|
2217
|
+
_h = 0, createdFormFieldIds_1 = createdFormFieldIds;
|
|
2218
|
+
_r.label = 65;
|
|
2219
|
+
case 65:
|
|
2220
|
+
if (!(_h < createdFormFieldIds_1.length)) return [3 /*break*/, 68];
|
|
2221
|
+
id = createdFormFieldIds_1[_h];
|
|
2222
|
+
return [4 /*yield*/, sdk.api.form_fields.deleteOne(id).catch(function () { return null; })];
|
|
2223
|
+
case 66:
|
|
2224
|
+
_r.sent();
|
|
2225
|
+
_r.label = 67;
|
|
2226
|
+
case 67:
|
|
2227
|
+
_h++;
|
|
2228
|
+
return [3 /*break*/, 65];
|
|
2229
|
+
case 68:
|
|
2230
|
+
_j = 0, createdFormIds_2 = createdFormIds;
|
|
2231
|
+
_r.label = 69;
|
|
2232
|
+
case 69:
|
|
2233
|
+
if (!(_j < createdFormIds_2.length)) return [3 /*break*/, 72];
|
|
2234
|
+
id = createdFormIds_2[_j];
|
|
2235
|
+
return [4 /*yield*/, sdk.api.forms.deleteOne(id).catch(function () { return null; })];
|
|
2236
|
+
case 70:
|
|
2237
|
+
_r.sent();
|
|
2238
|
+
_r.label = 71;
|
|
2239
|
+
case 71:
|
|
2240
|
+
_j++;
|
|
2241
|
+
return [3 /*break*/, 69];
|
|
2242
|
+
case 72:
|
|
2243
|
+
uniqueEnduserIds = createdEnduserIds.filter(function (id, i) { return !!id && createdEnduserIds.indexOf(id) === i; });
|
|
2244
|
+
_k = 0, uniqueEnduserIds_1 = uniqueEnduserIds;
|
|
2245
|
+
_r.label = 73;
|
|
2246
|
+
case 73:
|
|
2247
|
+
if (!(_k < uniqueEnduserIds_1.length)) return [3 /*break*/, 76];
|
|
2248
|
+
id = uniqueEnduserIds_1[_k];
|
|
2249
|
+
return [4 /*yield*/, sdk.api.endusers.deleteOne(id).catch(function () { return null; })];
|
|
2250
|
+
case 74:
|
|
2251
|
+
_r.sent();
|
|
2252
|
+
_r.label = 75;
|
|
2253
|
+
case 75:
|
|
2254
|
+
_k++;
|
|
2255
|
+
return [3 /*break*/, 73];
|
|
2256
|
+
case 76:
|
|
2257
|
+
_l = 0, createdTemplateIds_1 = createdTemplateIds;
|
|
2258
|
+
_r.label = 77;
|
|
2259
|
+
case 77:
|
|
2260
|
+
if (!(_l < createdTemplateIds_1.length)) return [3 /*break*/, 80];
|
|
2261
|
+
id = createdTemplateIds_1[_l];
|
|
2262
|
+
return [4 /*yield*/, sdk.api.calendar_event_templates.deleteOne(id).catch(function () { return null; })];
|
|
2263
|
+
case 78:
|
|
2264
|
+
_r.sent();
|
|
2265
|
+
_r.label = 79;
|
|
2266
|
+
case 79:
|
|
2267
|
+
_l++;
|
|
2268
|
+
return [3 /*break*/, 77];
|
|
2269
|
+
case 80:
|
|
2270
|
+
_m = 0, createdUserIds_1 = createdUserIds;
|
|
2271
|
+
_r.label = 81;
|
|
2272
|
+
case 81:
|
|
2273
|
+
if (!(_m < createdUserIds_1.length)) return [3 /*break*/, 84];
|
|
2274
|
+
id = createdUserIds_1[_m];
|
|
2275
|
+
return [4 /*yield*/, sdk.api.users.deleteOne(id).catch(function () { return null; })];
|
|
2276
|
+
case 82:
|
|
2277
|
+
_r.sent();
|
|
2278
|
+
_r.label = 83;
|
|
2279
|
+
case 83:
|
|
2280
|
+
_m++;
|
|
2281
|
+
return [3 /*break*/, 81];
|
|
2282
|
+
case 84:
|
|
2283
|
+
// ---------------------------------------------------------------------------------------------
|
|
2284
|
+
// Assert — single pass, last, so the full table above is always printed first.
|
|
2285
|
+
// ---------------------------------------------------------------------------------------------
|
|
2286
|
+
for (_o = 0, results_2 = results; _o < results_2.length; _o++) {
|
|
2287
|
+
r = results_2[_o];
|
|
2288
|
+
assert(r.ok, "\u274C [".concat(r.group, "] ").concat(r.name, " \u2014 ").concat(r.detail), "[".concat(r.group, "] ").concat(r.name).concat(r.skipped ? ' (SKIPPED)' : ''));
|
|
2289
|
+
}
|
|
2290
|
+
return [2 /*return*/];
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
});
|
|
2294
|
+
};
|
|
2295
|
+
// Allow running this test file independently
|
|
2296
|
+
if (require.main === module) {
|
|
2297
|
+
console.log("\uD83C\uDF10 Using API URL: ".concat(host));
|
|
2298
|
+
var sdk_1 = new Session({ host: host });
|
|
2299
|
+
var sdkNonAdmin_1 = new Session({ host: host });
|
|
2300
|
+
var runTests = function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2301
|
+
return __generator(this, function (_a) {
|
|
2302
|
+
switch (_a.label) {
|
|
2303
|
+
case 0: return [4 /*yield*/, setup_tests(sdk_1, sdkNonAdmin_1)];
|
|
2304
|
+
case 1:
|
|
2305
|
+
_a.sent();
|
|
2306
|
+
return [4 /*yield*/, public_endpoint_auth_field_lock_tests({ sdk: sdk_1, sdkNonAdmin: sdkNonAdmin_1 })];
|
|
2307
|
+
case 2:
|
|
2308
|
+
_a.sent();
|
|
2309
|
+
return [2 /*return*/];
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
}); };
|
|
2313
|
+
runTests()
|
|
2314
|
+
.then(function () {
|
|
2315
|
+
console.log("✅ Public endpoint auth field lock test suite completed successfully");
|
|
2316
|
+
process.exit(0);
|
|
2317
|
+
})
|
|
2318
|
+
.catch(function (error) {
|
|
2319
|
+
console.error("❌ Public endpoint auth field lock test suite failed:", error);
|
|
2320
|
+
process.exit(1);
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
//# sourceMappingURL=public_endpoint_auth_field_lock.test.js.map
|