@7365admin1/core 3.48.1-staging.185 → 3.48.1-staging.187
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/.changeset/bulletin-board-site-scope.md +19 -0
- package/.changeset/user-email-lookup-no-credentials.md +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +49 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +134 -83
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/bulletin-board-scope.e2e.test.mjs +442 -0
- package/test/e2e/harness.mjs +20 -0
- package/test/e2e/user-email-lookup-leak.e2e.test.mjs +190 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Scope the bulletin-board routes to the caller's own estate.
|
|
6
|
+
|
|
7
|
+
All five `/api/bulletin-boards` routes carried `requireAuth` and nothing more,
|
|
8
|
+
and the controller held no authorization identifier of any kind, so any
|
|
9
|
+
signed-in account in any organisation could publish an announcement into another
|
|
10
|
+
client's estate — which also fans out as a notification to every recipient group
|
|
11
|
+
at that site — read that board, and read, rewrite or delete any single bulletin
|
|
12
|
+
reached by id alone.
|
|
13
|
+
|
|
14
|
+
`add` and `getAll` now authorise the site named in the request with
|
|
15
|
+
`requireSiteReach`; `site` is optional on the add schema, so the org-wide case is
|
|
16
|
+
asked about separately rather than an absent site leaving the write unscoped. The
|
|
17
|
+
three by-id handlers take the verdict off the STORED row with a new module-named
|
|
18
|
+
`requireBulletinReach`, which answers "not yours" and "does not exist"
|
|
19
|
+
identically so bulletin ids cannot be enumerated.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/core": minor
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Stop the e-mail account lookup handing out the password hash and session id.
|
|
6
|
+
|
|
7
|
+
`GET /api/users/user/:email` (`user.route.ts:25`) has no guard on it at all, and
|
|
8
|
+
`GET /api/users/email/:email` next to it is the same handler behind
|
|
9
|
+
`requireAuth`. Both answered `res.json(user)` on the raw stored document, and
|
|
10
|
+
the users collection stores `password` — the hash — and `sid` — that account's
|
|
11
|
+
CURRENT session id.
|
|
12
|
+
|
|
13
|
+
So anyone at all, with no session, who knew or guessed an e-mail address got
|
|
14
|
+
that account's password hash back in the response body, to crack offline at
|
|
15
|
+
leisure, plus a live session id to reuse. On the authenticated twin, any signed
|
|
16
|
+
in account could read the same two fields for any other account, including an
|
|
17
|
+
administrator's.
|
|
18
|
+
|
|
19
|
+
The unguarded route cannot simply be guarded. Its only callers anywhere in the
|
|
20
|
+
organisation are pre-login signup screens — the resident app's e-mail check
|
|
21
|
+
(`user-registration.ts:103`) and the four `web-app-main/pages/sign-up-invite/*`
|
|
22
|
+
pages through `layer-common`'s `getUserByEmailV2` — so a session check would
|
|
23
|
+
break registration. The fix is therefore not authentication, it is not handing
|
|
24
|
+
out more than signup asks for.
|
|
25
|
+
|
|
26
|
+
All five of those callers read NO field off the reply. The mobile check does
|
|
27
|
+
`res !== null`; the four web pages do `if (response)`. So without a session the
|
|
28
|
+
handler no longer loads the document: a new `userExistsByEmail` does a
|
|
29
|
+
`findOne` with an explicit allow-list projection of `_id` alone and returns a
|
|
30
|
+
boolean, and the route answers `{ exists: true }` or `null` exactly as before.
|
|
31
|
+
An allow-list, deliberately — a deny-list would silently start leaking the next
|
|
32
|
+
credential-shaped field somebody adds to the collection.
|
|
33
|
+
|
|
34
|
+
`getUserByEmail` itself is UNCHANGED and still returns the whole document,
|
|
35
|
+
because signing in genuinely needs the hash to compare against. Putting the
|
|
36
|
+
projection on that function instead of beside it would have broken every login.
|
|
37
|
+
|
|
38
|
+
With a session the reply keeps its existing shape, minus `password` and `sid`,
|
|
39
|
+
via a small `withoutCredentials`. `_id` is the only field its callers read
|
|
40
|
+
(`web-app-main pages/verify/email`, `web-app-org onboarding/getting-started`)
|
|
41
|
+
and it survives. That path is narrowed by removal rather than by allow-list on
|
|
42
|
+
purpose: it is consumed by screens across web and mobile that cannot all be
|
|
43
|
+
enumerated from here, and removing two credential fields cannot break a
|
|
44
|
+
legitimate one, because no screen renders a password hash or a session id.
|
|
45
|
+
|
|
46
|
+
The v2 twin `GET /api/users/v2/email/:email` had the same leak — it is behind
|
|
47
|
+
`requireAuth`, so it was never anonymous, but "signed in" is not "entitled to
|
|
48
|
+
another account's credentials" — and is stripped the same way. v2 has no
|
|
49
|
+
anonymous `/user/:email` route and no pre-login caller.
|
|
50
|
+
|
|
51
|
+
Covered by `test/e2e/user-email-lookup-leak.e2e.test.mjs`, which asserts on the
|
|
52
|
+
absence of the key and never on its value.
|
package/dist/index.d.ts
CHANGED
|
@@ -168,6 +168,7 @@ declare function useUserRepo(): {
|
|
|
168
168
|
pageRange: string;
|
|
169
169
|
}>;
|
|
170
170
|
getUserByEmail: (email: string) => Promise<TUser | null>;
|
|
171
|
+
userExistsByEmail: (email: string) => Promise<boolean>;
|
|
171
172
|
getUserByReferralCode: (referralCode: string) => Promise<TUser | null>;
|
|
172
173
|
getByEmailApp: (email: string, app: string) => Promise<TUser | null>;
|
|
173
174
|
getUserById: (_id: string | ObjectId) => Promise<TUser & {
|
package/dist/index.js
CHANGED
|
@@ -8973,6 +8973,17 @@ function useUserRepo() {
|
|
|
8973
8973
|
throw new import_node_server_utils11.InternalServerError("Failed to get user by email.");
|
|
8974
8974
|
}
|
|
8975
8975
|
}
|
|
8976
|
+
async function userExistsByEmail(email) {
|
|
8977
|
+
try {
|
|
8978
|
+
const data = await collection.findOne(
|
|
8979
|
+
{ email: { $regex: `^${email}$`, $options: "i" } },
|
|
8980
|
+
{ projection: { _id: 1 } }
|
|
8981
|
+
);
|
|
8982
|
+
return Boolean(data);
|
|
8983
|
+
} catch (error) {
|
|
8984
|
+
throw new import_node_server_utils11.InternalServerError("Failed to get user by email.");
|
|
8985
|
+
}
|
|
8986
|
+
}
|
|
8976
8987
|
async function getUserByEmailStatus(email) {
|
|
8977
8988
|
try {
|
|
8978
8989
|
const cacheOptions = {
|
|
@@ -9624,6 +9635,7 @@ function useUserRepo() {
|
|
|
9624
9635
|
getAcceptedTerms,
|
|
9625
9636
|
listTermsAcceptance,
|
|
9626
9637
|
getUserByEmail,
|
|
9638
|
+
userExistsByEmail,
|
|
9627
9639
|
getUserByReferralCode,
|
|
9628
9640
|
getByEmailApp,
|
|
9629
9641
|
getUserById,
|
|
@@ -18238,11 +18250,20 @@ async function requirePlatformOwner(req) {
|
|
|
18238
18250
|
return id;
|
|
18239
18251
|
}
|
|
18240
18252
|
|
|
18253
|
+
// src/utils/user-response.util.ts
|
|
18254
|
+
function withoutCredentials(user) {
|
|
18255
|
+
if (!user || typeof user !== "object")
|
|
18256
|
+
return user;
|
|
18257
|
+
const { password, sid, ...rest } = user;
|
|
18258
|
+
return rest;
|
|
18259
|
+
}
|
|
18260
|
+
|
|
18241
18261
|
// src/controllers/user.controller.ts
|
|
18242
18262
|
function useUserController() {
|
|
18243
18263
|
const {
|
|
18244
18264
|
getUserById: _getById,
|
|
18245
18265
|
getUserByEmail: _getByEmail,
|
|
18266
|
+
userExistsByEmail: _existsByEmail,
|
|
18246
18267
|
getUsers: _getUsers,
|
|
18247
18268
|
updateBirthday: _updateBirthday,
|
|
18248
18269
|
updateUserFieldById: _updateUserFieldById,
|
|
@@ -18320,8 +18341,13 @@ function useUserController() {
|
|
|
18320
18341
|
return;
|
|
18321
18342
|
}
|
|
18322
18343
|
try {
|
|
18344
|
+
if (!callerId(req)) {
|
|
18345
|
+
const exists = await _existsByEmail(email);
|
|
18346
|
+
res.json(exists ? { exists: true } : null);
|
|
18347
|
+
return;
|
|
18348
|
+
}
|
|
18323
18349
|
const user = await _getByEmail(email);
|
|
18324
|
-
res.json(user);
|
|
18350
|
+
res.json(withoutCredentials(user));
|
|
18325
18351
|
return;
|
|
18326
18352
|
} catch (error2) {
|
|
18327
18353
|
import_node_server_utils42.logger.log({ level: "error", message: `${error2.message}` });
|
|
@@ -57728,6 +57754,15 @@ function useBulletinBoardController() {
|
|
|
57728
57754
|
deleteBulletinBoardById: _deleteBulletinBoardById
|
|
57729
57755
|
} = useBulletinBoardService();
|
|
57730
57756
|
const { getAll: _getAll, getBulletinBoardById: _getBulletinBoardById } = useBulletinBoardRepo();
|
|
57757
|
+
const scopeOf = (row) => ({ site: row?.site, org: row?.orgId });
|
|
57758
|
+
async function requireBulletinReach(req, id) {
|
|
57759
|
+
const bulletin = await _getBulletinBoardById(id);
|
|
57760
|
+
const { canReach } = await siteReachOf(req);
|
|
57761
|
+
if (!await canReach(scopeOf(bulletin))) {
|
|
57762
|
+
throw new import_node_server_utils174.NotFoundError("Bulletin board not found.");
|
|
57763
|
+
}
|
|
57764
|
+
return bulletin;
|
|
57765
|
+
}
|
|
57731
57766
|
async function add(req, res, next) {
|
|
57732
57767
|
const { error, value } = schemaBulletinBoard.validate(req.body, {
|
|
57733
57768
|
abortEarly: false
|
|
@@ -57739,6 +57774,14 @@ function useBulletinBoardController() {
|
|
|
57739
57774
|
return;
|
|
57740
57775
|
}
|
|
57741
57776
|
try {
|
|
57777
|
+
if (value.site) {
|
|
57778
|
+
await requireSiteReach(req, value.site);
|
|
57779
|
+
} else {
|
|
57780
|
+
const { canReach } = await siteReachOf(req);
|
|
57781
|
+
if (!await canReach(scopeOf(value))) {
|
|
57782
|
+
throw new import_node_server_utils174.UnauthorizedError("Not authorized.");
|
|
57783
|
+
}
|
|
57784
|
+
}
|
|
57742
57785
|
const data = await _add(value);
|
|
57743
57786
|
res.status(201).json(data);
|
|
57744
57787
|
return;
|
|
@@ -57811,6 +57854,7 @@ function useBulletinBoardController() {
|
|
|
57811
57854
|
[sort ? sort : "_id" /* ID */]: order === "asc" /* ASC */ ? 1 : -1
|
|
57812
57855
|
};
|
|
57813
57856
|
try {
|
|
57857
|
+
await requireSiteReach(req, site);
|
|
57814
57858
|
const data = await _getAll({
|
|
57815
57859
|
search,
|
|
57816
57860
|
page,
|
|
@@ -57841,7 +57885,7 @@ function useBulletinBoardController() {
|
|
|
57841
57885
|
return;
|
|
57842
57886
|
}
|
|
57843
57887
|
const { _id } = value;
|
|
57844
|
-
const data = await
|
|
57888
|
+
const data = await requireBulletinReach(req, _id);
|
|
57845
57889
|
res.status(200).json(data);
|
|
57846
57890
|
return;
|
|
57847
57891
|
} catch (error) {
|
|
@@ -57863,6 +57907,7 @@ function useBulletinBoardController() {
|
|
|
57863
57907
|
return;
|
|
57864
57908
|
}
|
|
57865
57909
|
try {
|
|
57910
|
+
await requireBulletinReach(req, _id);
|
|
57866
57911
|
const result = await _updateBulletinBoardById(_id, req.body);
|
|
57867
57912
|
res.status(200).json({ message: result });
|
|
57868
57913
|
return;
|
|
@@ -57884,6 +57929,7 @@ function useBulletinBoardController() {
|
|
|
57884
57929
|
}
|
|
57885
57930
|
const { _id } = value;
|
|
57886
57931
|
try {
|
|
57932
|
+
await requireBulletinReach(req, _id);
|
|
57887
57933
|
await _deleteBulletinBoardById(_id);
|
|
57888
57934
|
res.status(200).json({ message: "Successfully deleted bulletin board." });
|
|
57889
57935
|
return;
|
|
@@ -82147,7 +82193,7 @@ function useUserControllerV2() {
|
|
|
82147
82193
|
return rejectValidation(error, next);
|
|
82148
82194
|
try {
|
|
82149
82195
|
const user = await _getUserByEmail(value);
|
|
82150
|
-
res.json(user);
|
|
82196
|
+
res.json(withoutCredentials(user));
|
|
82151
82197
|
return;
|
|
82152
82198
|
} catch (err) {
|
|
82153
82199
|
import_node_server_utils267.logger.log({ level: "error", message: `${err.message}` });
|