@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.
@@ -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 _getBulletinBoardById(_id);
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}` });