@7365admin1/core 3.48.1-staging.186 → 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/user-email-lookup-no-credentials.md +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +28 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +28 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/test/e2e/user-email-lookup-leak.e2e.test.mjs +190 -0
|
@@ -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}` });
|
|
@@ -82167,7 +82193,7 @@ function useUserControllerV2() {
|
|
|
82167
82193
|
return rejectValidation(error, next);
|
|
82168
82194
|
try {
|
|
82169
82195
|
const user = await _getUserByEmail(value);
|
|
82170
|
-
res.json(user);
|
|
82196
|
+
res.json(withoutCredentials(user));
|
|
82171
82197
|
return;
|
|
82172
82198
|
} catch (err) {
|
|
82173
82199
|
import_node_server_utils267.logger.log({ level: "error", message: `${err.message}` });
|