@7365admin1/core 3.52.17 → 3.52.18
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/CHANGELOG.md +22 -0
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/harness.mjs +5 -2
- package/test/e2e/subscription-read-scope.e2e.test.mjs +182 -0
- package/test/e2e/user-email-lookup-leak.e2e.test.mjs +68 -1
package/package.json
CHANGED
package/test/e2e/harness.mjs
CHANGED
|
@@ -345,8 +345,11 @@ function buildApp(nsu, core) {
|
|
|
345
345
|
// mirrors iservice365-API-core/src/routes/subscription.route.ts
|
|
346
346
|
const subscriptions = express.Router();
|
|
347
347
|
{
|
|
348
|
-
const { updateSubscriptionSeats } =
|
|
349
|
-
|
|
348
|
+
const { updateSubscriptionSeats, getByOrgId, getSubscriptions } =
|
|
349
|
+
core.useSubscriptionController();
|
|
350
|
+
// subscription.route.ts:21-24
|
|
351
|
+
subscriptions.get("/", requireAuth, getSubscriptions);
|
|
352
|
+
subscriptions.get("/org/:id", requireAuth, getByOrgId);
|
|
350
353
|
subscriptions.put("/seats/:id", requireAuth, updateSubscriptionSeats);
|
|
351
354
|
}
|
|
352
355
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// End-to-end proof that a client's billing record is not readable by every
|
|
2
|
+
// signed-in account on the platform.
|
|
3
|
+
//
|
|
4
|
+
// Two reads on `subscription.route.ts` carried `requireAuth` and nothing else:
|
|
5
|
+
//
|
|
6
|
+
// - `GET /api/subscriptions/org/:id` — ONE organisation's subscription: its
|
|
7
|
+
// plan, seat count, price, currency and renewal date. Any signed-in
|
|
8
|
+
// account — a resident, a guard, a cleaner, another client's admin — could
|
|
9
|
+
// read any organisation's, given only the org id.
|
|
10
|
+
// - `GET /api/subscriptions/` — EVERY subscription on the platform in one
|
|
11
|
+
// searchable, paged list. Not filtered by organisation at all: that is the
|
|
12
|
+
// Seven365 staff console answer, and it was open to anybody signed in.
|
|
13
|
+
//
|
|
14
|
+
// The gates are the two already used elsewhere in the same file:
|
|
15
|
+
// `requireOrgAccess(req, org)` (the pattern `updateSubscriptionSeats` uses on
|
|
16
|
+
// the same records) and `requirePlatformStaff(req)` (the console gate).
|
|
17
|
+
//
|
|
18
|
+
// No live caller loses access. `/org/:id` has exactly one caller — web-app-org's
|
|
19
|
+
// own subscription and manage-seats pages — and both pass `currentOrg`, the
|
|
20
|
+
// caller's OWN organisation. The list has no caller at all: layer-common
|
|
21
|
+
// exports `getSubscriptions()` and no screen anywhere calls it.
|
|
22
|
+
//
|
|
23
|
+
// Everything runs through the real Express stack against the harness's
|
|
24
|
+
// in-process MongoDB replica set. No staging or production database is touched,
|
|
25
|
+
// no payment gateway is contacted, and every seeded value is obviously fake.
|
|
26
|
+
//
|
|
27
|
+
// Run with: yarn test:e2e
|
|
28
|
+
|
|
29
|
+
import { after, before, describe, it } from "node:test";
|
|
30
|
+
import assert from "node:assert/strict";
|
|
31
|
+
import { ObjectId } from "mongodb";
|
|
32
|
+
|
|
33
|
+
import { startHarness } from "./harness.mjs";
|
|
34
|
+
|
|
35
|
+
const PASSWORD = "Sub-Read-Passw0rd!";
|
|
36
|
+
const OWN_ADMIN = "sub-read-own@e2e.example.com";
|
|
37
|
+
const OTHER_ADMIN = "sub-read-other@e2e.example.com";
|
|
38
|
+
const STAFF = "sub-read-staff@e2e.example.com";
|
|
39
|
+
|
|
40
|
+
describe("subscription reads are scoped", { concurrency: 1 }, () => {
|
|
41
|
+
let h;
|
|
42
|
+
let id;
|
|
43
|
+
let ownSid;
|
|
44
|
+
let otherSid;
|
|
45
|
+
let staffSid;
|
|
46
|
+
const result = [];
|
|
47
|
+
|
|
48
|
+
const record = (name, fn) =>
|
|
49
|
+
it(name, async () => {
|
|
50
|
+
try {
|
|
51
|
+
await fn();
|
|
52
|
+
result.push(["PASS", name]);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
result.push(["FAIL", name]);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
before(async () => {
|
|
60
|
+
h = await startHarness();
|
|
61
|
+
id = await seed(h);
|
|
62
|
+
ownSid = await h.login(OWN_ADMIN, PASSWORD);
|
|
63
|
+
otherSid = await h.login(OTHER_ADMIN, PASSWORD);
|
|
64
|
+
staffSid = await h.login(STAFF, PASSWORD);
|
|
65
|
+
}, { timeout: 300000 });
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
if (h) await h.stop();
|
|
69
|
+
console.log("\n--- subscription read scope: per-case result ---");
|
|
70
|
+
for (const [verdict, name] of result) console.log(`${verdict} ${name}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const byOrg = (org, sid) =>
|
|
74
|
+
h.api(`/subscriptions/org/${org.toString()}`, { sid });
|
|
75
|
+
|
|
76
|
+
const list = (sid) => h.api("/subscriptions/?status=active", { sid });
|
|
77
|
+
|
|
78
|
+
// ---- GET /subscriptions/org/:id ----------------------------------------
|
|
79
|
+
|
|
80
|
+
record("1. same tenant: the org's own admin still reads its subscription", async () => {
|
|
81
|
+
const res = await byOrg(id.ownOrg, ownSid);
|
|
82
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
83
|
+
// The page renders the plan and the seat numbers, so the record itself must
|
|
84
|
+
// still come back, not an empty shell.
|
|
85
|
+
assert.equal(res.body?._id, id.ownSub.toString());
|
|
86
|
+
assert.equal(res.body?.maxSeats, 5);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
record("2. cross tenant: another client's admin is refused", async () => {
|
|
90
|
+
const res = await byOrg(id.ownOrg, otherSid);
|
|
91
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
record("3. and learns nothing about the record from the refusal", async () => {
|
|
95
|
+
const res = await byOrg(id.ownOrg, otherSid);
|
|
96
|
+
const body = JSON.stringify(res.body ?? {});
|
|
97
|
+
for (const key of ["maxSeats", "currency", "nextBillingDate"]) {
|
|
98
|
+
assert.ok(!body.includes(key), `refusal must not disclose ${key}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
record("4. the other admin still reads their OWN organisation", async () => {
|
|
103
|
+
// Proves case 2 refused for the right reason — the gate, not a broken route.
|
|
104
|
+
const res = await byOrg(id.otherOrg, otherSid);
|
|
105
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
106
|
+
assert.equal(res.body?._id, id.otherSub.toString());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
record("5. no session at all is still refused", async () => {
|
|
110
|
+
const res = await byOrg(id.ownOrg);
|
|
111
|
+
assert.equal(res.status, 401, JSON.stringify(res.status));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---- GET /subscriptions/ ------------------------------------------------
|
|
115
|
+
|
|
116
|
+
record("6. the platform-wide list is refused to an ordinary client admin", async () => {
|
|
117
|
+
const res = await list(ownSid);
|
|
118
|
+
assert.equal(res.status, 401, JSON.stringify(res.body));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
record("7. and does not leak the other client's row in the refusal", async () => {
|
|
122
|
+
const res = await list(ownSid);
|
|
123
|
+
assert.ok(
|
|
124
|
+
!JSON.stringify(res.body ?? {}).includes(id.otherSub.toString()),
|
|
125
|
+
"a refused list must not carry any subscription id",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
record("8. Seven365 staff still get the list", async () => {
|
|
130
|
+
// The console is the legitimate caller. If this fails the gate is too tight.
|
|
131
|
+
const res = await list(staffSid);
|
|
132
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function seed(h) {
|
|
137
|
+
const now = new Date().toISOString();
|
|
138
|
+
const nextBillingDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
|
|
139
|
+
const hashed = await h.hashPassword(PASSWORD);
|
|
140
|
+
|
|
141
|
+
const ownOrg = new ObjectId();
|
|
142
|
+
const otherOrg = new ObjectId();
|
|
143
|
+
const ownSub = new ObjectId();
|
|
144
|
+
const otherSub = new ObjectId();
|
|
145
|
+
const ownRole = new ObjectId();
|
|
146
|
+
const otherRole = new ObjectId();
|
|
147
|
+
const staffRole = new ObjectId();
|
|
148
|
+
|
|
149
|
+
await h.db.collection("organizations").insertMany([
|
|
150
|
+
{ _id: ownOrg, name: "Sub Read Own", email: "sub-read-own-org@e2e.example.com", type: "property-management", status: "active", createdAt: now },
|
|
151
|
+
{ _id: otherOrg, name: "Sub Read Other", email: "sub-read-other-org@e2e.example.com", type: "property-management", status: "active", createdAt: now },
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
await h.db.collection("roles").insertMany([
|
|
155
|
+
{ _id: ownRole, name: "Organisation Staff", org: ownOrg, status: "active", permissions: [] },
|
|
156
|
+
{ _id: otherRole, name: "Organisation Staff", org: otherOrg, status: "active", permissions: [] },
|
|
157
|
+
// Staff is a MEMBERSHIP of type "admin" whose role is also type "admin" —
|
|
158
|
+
// never a role merely NAMED "Super Admin". See `super-admin.util.ts`.
|
|
159
|
+
{ _id: staffRole, name: "Super Admin", type: "admin", default: true, status: "active", permissions: [] },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const users = await h.db.collection("users").insertMany([
|
|
163
|
+
{ email: OWN_ADMIN, password: hashed, name: "Own Admin", status: "active", defaultOrg: ownOrg.toString(), createdAt: now },
|
|
164
|
+
{ email: OTHER_ADMIN, password: hashed, name: "Other Admin", status: "active", defaultOrg: otherOrg.toString(), createdAt: now },
|
|
165
|
+
{ email: STAFF, password: hashed, name: "Platform Staff", status: "active", createdAt: now },
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const [own, other, staff] = [0, 1, 2].map((i) => users.insertedIds[i]);
|
|
169
|
+
|
|
170
|
+
await h.db.collection("members").insertMany([
|
|
171
|
+
{ user: own, org: ownOrg, role: ownRole, type: "organization", status: "active" },
|
|
172
|
+
{ user: other, org: otherOrg, role: otherRole, type: "organization", status: "active" },
|
|
173
|
+
{ user: staff, type: "admin", role: staffRole, status: "active" },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
await h.db.collection("subscriptions").insertMany([
|
|
177
|
+
{ _id: ownSub, org: ownOrg, user: own, type: "organization", status: "active", currency: "SGD", maxSeats: 5, paidSeats: 5, currentSeats: 1, billingCycle: "monthly", nextBillingDate, createdAt: now },
|
|
178
|
+
{ _id: otherSub, org: otherOrg, user: other, type: "organization", status: "active", currency: "SGD", maxSeats: 9, paidSeats: 9, currentSeats: 1, billingCycle: "monthly", nextBillingDate, createdAt: now },
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
return { ownOrg, otherOrg, ownSub, otherSub };
|
|
182
|
+
}
|
|
@@ -37,6 +37,10 @@ const UNKNOWN = "leak-nobody@e2e.example.com";
|
|
|
37
37
|
// `sid` are the two the users collection actually stores today; the rest are
|
|
38
38
|
// listed so that the day one of them is added, this test fails rather than the
|
|
39
39
|
// endpoint quietly starting to leak it.
|
|
40
|
+
// The identity fields the users collection stores. None of them is a credential,
|
|
41
|
+
// which is exactly why the remove-list left every one of them on the wire.
|
|
42
|
+
const PERSONAL_KEYS = ["nric", "contact", "dateOfBirth", "gender", "defaultOrg", "status"];
|
|
43
|
+
|
|
40
44
|
const CREDENTIAL_KEYS = [
|
|
41
45
|
"password",
|
|
42
46
|
"sid",
|
|
@@ -151,6 +155,56 @@ describe("account lookup by e-mail never returns credentials", { concurrency: 1
|
|
|
151
155
|
assert.ok(res.body?._id, "the authenticated reply must still carry _id");
|
|
152
156
|
});
|
|
153
157
|
|
|
158
|
+
record("7a. and gets ONLY the three allow-listed identity fields", async () => {
|
|
159
|
+
// The remove-list left everything that was not a credential on the wire, so
|
|
160
|
+
// knowing an e-mail address bought the whole identity record. This is the
|
|
161
|
+
// case that pins the reply to an allow-list.
|
|
162
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
Object.keys(res.body ?? {}).sort(),
|
|
165
|
+
["_id", "email", "name"],
|
|
166
|
+
"the authenticated reply must be exactly {_id, name, email}",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
record("7b. specifically, none of the personal-detail fields", async () => {
|
|
171
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
172
|
+
const leaked = PERSONAL_KEYS.filter((k) => k in (res.body ?? {}));
|
|
173
|
+
assert.deepEqual(leaked, [], `personal detail leaked: ${leaked.join(", ")}`);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
record("7c. the seeded record really does carry those details", async () => {
|
|
177
|
+
// Without this, 7b would pass just as well against an account that never
|
|
178
|
+
// had an NRIC in the first place.
|
|
179
|
+
const stored = await h.db.collection("users").findOne({ email: KNOWN });
|
|
180
|
+
const present = PERSONAL_KEYS.filter((k) => k in (stored ?? {}));
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
present.sort(),
|
|
183
|
+
[...PERSONAL_KEYS].sort(),
|
|
184
|
+
"the seeded account must hold every field the reply is being tested for",
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
record("7d. the invite flow still works end to end on the narrowed reply", async () => {
|
|
189
|
+
// What all six live call sites actually do: look the invitee up, and use
|
|
190
|
+
// `_id` (and, in ClientDetailForm, `name`) from the reply. If the allow-list
|
|
191
|
+
// had dropped either, this is the case that breaks.
|
|
192
|
+
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
193
|
+
assert.ok(res.body?._id, "InvitationClientForm / ServiceProviderMain read _id");
|
|
194
|
+
assert.equal(res.body?.name, "Known Account", "ClientDetailForm reads name");
|
|
195
|
+
assert.equal(res.body?.email, KNOWN, "the caller's own input, echoed back");
|
|
196
|
+
|
|
197
|
+
// ...and it is the RIGHT id — the one the forms then pass to the membership
|
|
198
|
+
// lookup — not merely some id-shaped value.
|
|
199
|
+
assert.equal(res.body._id, id.known.toString(), "must identify the seeded account");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
record("7e. an e-mail nobody holds still answers, and answers nothing", async () => {
|
|
203
|
+
const res = await h.api(`/users/email/${UNKNOWN}`, { sid: callerSid });
|
|
204
|
+
assert.equal(res.status, 200, JSON.stringify(res.status));
|
|
205
|
+
assert.ok(!res.body?._id, "an unknown e-mail must not come back with an id");
|
|
206
|
+
});
|
|
207
|
+
|
|
154
208
|
record("8. but that reply is stripped of credentials too", async () => {
|
|
155
209
|
const res = await h.api(`/users/email/${KNOWN}`, { sid: callerSid });
|
|
156
210
|
const leaked = CREDENTIAL_KEYS.filter((k) => k in (res.body ?? {}));
|
|
@@ -164,6 +218,17 @@ describe("account lookup by e-mail never returns credentials", { concurrency: 1
|
|
|
164
218
|
assert.deepEqual(leaked, [], `credential keys leaked: ${leaked.join(", ")}`);
|
|
165
219
|
});
|
|
166
220
|
|
|
221
|
+
record("9a. the v2 twin is narrowed to the same three fields", async () => {
|
|
222
|
+
// It has no callers anywhere in the organisation, so it can be narrowed
|
|
223
|
+
// without asking anyone — and leaving it wide would just move the leak.
|
|
224
|
+
const res = await h.api(`/users/v2/email/${KNOWN}`, { sid: callerSid });
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
Object.keys(res.body ?? {}).sort(),
|
|
227
|
+
["_id", "email", "name"],
|
|
228
|
+
"the v2 reply must be exactly {_id, name, email}",
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
167
232
|
// ---- and the internal path that genuinely needs the hash is untouched ---
|
|
168
233
|
|
|
169
234
|
record("10. signing in still works — the hash path is not projected away", async () => {
|
|
@@ -182,7 +247,9 @@ async function seed(h) {
|
|
|
182
247
|
const users = await h.db.collection("users").insertMany([
|
|
183
248
|
// A live `sid` is seeded deliberately: it is the second credential on this
|
|
184
249
|
// document and leaking it is a ready-made session hijack.
|
|
185
|
-
|
|
250
|
+
// The personal detail is seeded so the allow-list cases test a record that
|
|
251
|
+
// genuinely holds it. Every value is obviously fake and none is printed.
|
|
252
|
+
{ email: KNOWN, name: "Known Account", status: "active", password: hashed, sid: "e2e-not-a-real-session", nric: "S0000000Z", contact: "+65 0000 0000", dateOfBirth: "1970-01-01", gender: "unspecified", defaultOrg: "000000000000000000000000", createdAt: now },
|
|
186
253
|
{ email: CALLER, name: "Caller Account", status: "active", password: hashed, sid: "e2e-not-a-real-session-2", createdAt: now },
|
|
187
254
|
]);
|
|
188
255
|
|