@memberjunction/server 5.51.0 → 5.51.2
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/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +53 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +19 -2
- package/dist/context.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +18 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +46 -16
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ReportResolver.d.ts.map +1 -1
- package/dist/resolvers/ReportResolver.js +2 -1
- package/dist/resolvers/ReportResolver.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/package.json +89 -89
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/ResolverBase.frozenRecordMapping.test.ts +135 -0
- package/src/__tests__/ResolverBase.transportMapping.test.ts +290 -0
- package/src/__tests__/newUsers.test.ts +729 -0
- package/src/auth/index.ts +56 -15
- package/src/config.ts +12 -0
- package/src/context.ts +19 -2
- package/src/generic/ResolverBase.ts +46 -16
- package/src/index.ts +3 -1
- package/src/resolvers/ReportResolver.ts +2 -1
- package/src/resolvers/__tests__/ReportResolver.test.ts +232 -0
- package/src/rest/OAuthCallbackHandler.ts +149 -41
package/src/auth/index.ts
CHANGED
|
@@ -186,6 +186,42 @@ export const getSystemUser = async (dataSource?: sql.ConnectionPool, attemptCach
|
|
|
186
186
|
return systemUser;
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Extracts the lowercased domain portion of an email address.
|
|
191
|
+
*
|
|
192
|
+
* @returns the domain, or an empty string when the value is not an email address (e.g. an IdP that
|
|
193
|
+
* issues a bare username). Callers MUST treat an empty result as "cannot be authorized"
|
|
194
|
+
* rather than as a wildcard.
|
|
195
|
+
*/
|
|
196
|
+
const extractEmailDomain = (email: string): string => {
|
|
197
|
+
const parts = email.split('@');
|
|
198
|
+
// Reject anything that isn't exactly local@domain — a value with 0 or 2+ '@' is not an address we
|
|
199
|
+
// can make a trust decision about.
|
|
200
|
+
if (parts.length !== 2) return '';
|
|
201
|
+
return parts[1].toLowerCase().trim();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Tests a domain against `userHandling.newUserAuthorizedDomains`, honoring `*` wildcards.
|
|
206
|
+
*
|
|
207
|
+
* Note that a pattern is matched in full, so `*.example.com` matches `mail.example.com` but NOT
|
|
208
|
+
* `example.com` — list both if you need both.
|
|
209
|
+
*/
|
|
210
|
+
const isDomainAuthorized = (domain: string): boolean =>
|
|
211
|
+
configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
212
|
+
// Convert wildcard domain patterns to regular expressions
|
|
213
|
+
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
214
|
+
return regex.test(domain);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolves a verified identity to an MJ `UserInfo`, optionally auto-provisioning a new user.
|
|
219
|
+
*
|
|
220
|
+
* @param requestDomain the hostname parsed from the request's `Origin` header. **Not used for any
|
|
221
|
+
* authorization decision** — it is spoofable on non-browser requests, and new-user domain
|
|
222
|
+
* authorization runs against the verified JWT's email domain instead. Retained for audit
|
|
223
|
+
* logging and for the recursive retry call.
|
|
224
|
+
*/
|
|
189
225
|
export const verifyUserRecord = async (
|
|
190
226
|
email?: string,
|
|
191
227
|
firstName?: string,
|
|
@@ -206,23 +242,21 @@ export const verifyUserRecord = async (
|
|
|
206
242
|
});
|
|
207
243
|
|
|
208
244
|
if (!user) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
//
|
|
245
|
+
// NOTE: `requestDomain` (parsed from the spoofable `Origin` header) is deliberately NOT part of
|
|
246
|
+
// this condition. It was previously required here, which meant a non-browser client sending no
|
|
247
|
+
// Origin could never auto-provision while an attacker simply forged one — it gated entry without
|
|
248
|
+
// authorizing anything. Authorization happens below, against the verified identity's email domain.
|
|
249
|
+
if (configInfo.userHandling.autoCreateNewUsers && firstName && lastName) {
|
|
250
|
+
// SECURITY: authorize against the EMAIL DOMAIN of the cryptographically-verified identity,
|
|
251
|
+
// NOT the request `Origin` header. Origin is trivially spoofable on non-browser / bearer-token
|
|
252
|
+
// requests, so gating on it let a holder of any valid IdP token auto-provision an account under
|
|
253
|
+
// an authorized domain by sending a forged Origin. The email comes from the verified JWT.
|
|
254
|
+
const emailDomain: string = extractEmailDomain(email);
|
|
216
255
|
let passesDomainCheck: boolean =
|
|
217
256
|
configInfo.userHandling.newUserLimitedToAuthorizedDomains ===
|
|
218
257
|
false; /*in this first condition, we are set up to NOT care about domain */
|
|
219
|
-
if (!passesDomainCheck
|
|
220
|
-
|
|
221
|
-
passesDomainCheck = configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
222
|
-
// Convert wildcard domain patterns to regular expressions
|
|
223
|
-
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
224
|
-
return regex.test(requestDomain?.toLowerCase().trim());
|
|
225
|
-
});
|
|
258
|
+
if (!passesDomainCheck) {
|
|
259
|
+
passesDomainCheck = emailDomain.length > 0 && isDomainAuthorized(emailDomain);
|
|
226
260
|
}
|
|
227
261
|
|
|
228
262
|
if (passesDomainCheck) {
|
|
@@ -248,9 +282,16 @@ export const verifyUserRecord = async (
|
|
|
248
282
|
UserCache.Instance.Users.push(user);
|
|
249
283
|
console.warn(` >>> New user ${email} created successfully!`);
|
|
250
284
|
}
|
|
285
|
+
} else if (emailDomain.length === 0) {
|
|
286
|
+
// The verified identity carries no email domain at all — typically an IdP that issues a bare
|
|
287
|
+
// `preferred_username` with no `email` claim. There is nothing to match against, so the gate
|
|
288
|
+
// denies rather than falling back to anything spoofable.
|
|
289
|
+
console.warn(
|
|
290
|
+
`User ${email} not found in cache and will NOT be auto-created: the verified identity has no email domain (no '@'), so it cannot be matched against newUserAuthorizedDomains. This usually means the identity provider issues a username rather than an email address — configure it to emit an 'email' claim, or set newUserLimitedToAuthorizedDomains to false to disable domain checking.`
|
|
291
|
+
);
|
|
251
292
|
} else {
|
|
252
293
|
console.warn(
|
|
253
|
-
`User ${email} not found in cache.
|
|
294
|
+
`User ${email} not found in cache. Email domain '${emailDomain}' does not match any of the domains in the newUserAuthorizedDomains setting. NOTE: this check is against the EMAIL DOMAIN of the verified identity, NOT the browser Origin — if newUserAuthorizedDomains lists frontend hostnames (e.g. 'app.example.com'), replace them with email domains (e.g. 'example.com'). To ignore domain, make sure you set the newUserLimitedToAuthorizedDomains setting to false. In this case we are NOT creating a new user.`
|
|
254
295
|
);
|
|
255
296
|
}
|
|
256
297
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,7 +7,19 @@ const explorer = cosmiconfigSync('mj', { searchStrategy: 'global' });
|
|
|
7
7
|
|
|
8
8
|
const userHandlingInfoSchema = z.object({
|
|
9
9
|
autoCreateNewUsers: z.boolean().optional().default(false),
|
|
10
|
+
/** When true, auto-provisioning is restricted to the domains in `newUserAuthorizedDomains`. */
|
|
10
11
|
newUserLimitedToAuthorizedDomains: z.boolean().optional().default(false),
|
|
12
|
+
/**
|
|
13
|
+
* Authorized **email domains** for auto-provisioned users — e.g. `['example.com', '*.example.org']`.
|
|
14
|
+
*
|
|
15
|
+
* These are matched against the domain of the email address in the verified identity token, NOT
|
|
16
|
+
* against the browser `Origin` / frontend hostname. If you are upgrading from a build that
|
|
17
|
+
* compared these to the request origin, replace any frontend hostnames here (`app.example.com`,
|
|
18
|
+
* `localhost`) with the email domains your users actually sign in with.
|
|
19
|
+
*
|
|
20
|
+
* `*` wildcards are supported and match in full: `*.example.com` matches `mail.example.com` but
|
|
21
|
+
* NOT `example.com` — list both if you need both.
|
|
22
|
+
*/
|
|
11
23
|
newUserAuthorizedDomains: z.array(z.string()).optional().default([]),
|
|
12
24
|
newUserRoles: z.array(z.string()).optional().default([]),
|
|
13
25
|
updateCacheWhenNotFound: z.boolean().optional().default(false),
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IncomingMessage } from 'http';
|
|
2
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
2
3
|
import { default as jwt } from 'jsonwebtoken';
|
|
3
4
|
import 'reflect-metadata';
|
|
4
5
|
import { Subject, firstValueFrom } from 'rxjs';
|
|
@@ -319,7 +320,13 @@ const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayloa
|
|
|
319
320
|
return;
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
const verifyOptions: jwt.VerifyOptions = {
|
|
323
|
+
const verifyOptions: jwt.VerifyOptions = {
|
|
324
|
+
// SECURITY: explicitly pin the accepted signature algorithms to the asymmetric family.
|
|
325
|
+
// The signing key here comes from the issuer's JWKS (an RSA/EC public key), so without an
|
|
326
|
+
// explicit allow-list a future key-format change or library regression could reintroduce
|
|
327
|
+
// classic `alg=none` / RS256->HS256 confusion attacks. Pinning fails such tokens closed.
|
|
328
|
+
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256'],
|
|
329
|
+
};
|
|
323
330
|
if (Array.isArray(options.audience)) {
|
|
324
331
|
verifyOptions.audience = options.audience as [string, ...string[]];
|
|
325
332
|
} else {
|
|
@@ -420,7 +427,17 @@ export const getUserPayload = async (
|
|
|
420
427
|
// Check for system API key (x-mj-api-key header)
|
|
421
428
|
// This authenticates as the system user for system-level operations
|
|
422
429
|
if (systemApiKey && systemApiKey != String(undefined)) {
|
|
423
|
-
|
|
430
|
+
// SECURITY: compare the superadmin system API key in constant time. A plain `===`
|
|
431
|
+
// short-circuits on the first differing byte, leaking a timing side-channel that could
|
|
432
|
+
// be used to recover the key byte-by-byte. Hash both sides to fixed-length digests so
|
|
433
|
+
// timingSafeEqual never throws on length mismatch and the comparison is length-agnostic.
|
|
434
|
+
// The Uint8Array wrappers are required by the @types/node version this line is pinned to
|
|
435
|
+
// (20.14.2, via the root `overrides`), whose `timingSafeEqual` takes NodeJS.ArrayBufferView —
|
|
436
|
+
// a type its own non-generic `Buffer` does not satisfy under TypeScript 5.9's lib. Copying
|
|
437
|
+
// two fixed-length 32-byte digests is content-independent, so the comparison stays constant time.
|
|
438
|
+
const systemKeyDigest = new Uint8Array(createHash('sha256').update(String(systemApiKey)).digest());
|
|
439
|
+
const providedKeyDigest = new Uint8Array(createHash('sha256').update(String(apiKey)).digest());
|
|
440
|
+
if (timingSafeEqual(systemKeyDigest, providedKeyDigest)) {
|
|
424
441
|
const systemUser = await getSystemUser(readOnlyDataSource);
|
|
425
442
|
return {
|
|
426
443
|
userRecord: systemUser,
|
|
@@ -62,16 +62,27 @@ export class ResolverBase {
|
|
|
62
62
|
* - AllowDecryptInAPI=false + SendEncryptedValue=true: Keep encrypted ciphertext
|
|
63
63
|
* - AllowDecryptInAPI=false + SendEncryptedValue=false: Replace with sentinel
|
|
64
64
|
*
|
|
65
|
+
* Returns a COPY — `dataObject` is never written to. Callers routinely pass rows straight
|
|
66
|
+
* from `findBy`/`RunView`, which are the server cache's own objects held by reference under a
|
|
67
|
+
* reference-sharing storage provider. Renaming in place therefore rewrote the cached row's
|
|
68
|
+
* keys, so later readers were served transport-shaped rows that `BaseEntity.SetMany` rejects —
|
|
69
|
+
* and because that cache is process-wide, one response corrupted every subsequent request
|
|
70
|
+
* across all workers. It bit every `UserByEmail` / `UserByID` / `UserByEmployeeID` call and
|
|
71
|
+
* every generated single-record resolver whose entity has caching enabled. Copying here fixes
|
|
72
|
+
* every call site at once and makes the hazard unreachable for future ones.
|
|
73
|
+
*
|
|
65
74
|
* @param entityName - The entity name
|
|
66
|
-
* @param dataObject - The data object with field values
|
|
75
|
+
* @param dataObject - The data object with field values. Not modified.
|
|
67
76
|
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
68
|
-
* @returns
|
|
77
|
+
* @returns A new object in transport shape, or null when there is nothing to map
|
|
69
78
|
*/
|
|
70
79
|
protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<any> {
|
|
71
80
|
// Return null for empty objects (e.g. when no rows found due to RLS filtering)
|
|
72
81
|
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
73
82
|
return null;
|
|
74
83
|
}
|
|
84
|
+
// Shallow copy up front so every write below lands on our object, never the caller's.
|
|
85
|
+
dataObject = { ...dataObject };
|
|
75
86
|
|
|
76
87
|
// for the given entity name provided, check to see if there are any fields
|
|
77
88
|
// where the code name is different from the field name, and for just those
|
|
@@ -184,14 +195,21 @@ export class ResolverBase {
|
|
|
184
195
|
return true;
|
|
185
196
|
}
|
|
186
197
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Array form of {@link MapFieldNamesToCodeNames}. Returns a NEW array of NEW objects; neither
|
|
200
|
+
* the input array nor its rows are modified. Both matter: a reference-sharing cache hands back
|
|
201
|
+
* the stored array itself, not a copy of it, so collecting the mapped copies (rather than
|
|
202
|
+
* mapping in place and returning the original) is what makes this safe on cache-served input.
|
|
203
|
+
*/
|
|
204
|
+
protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo, provider?: IMetadataProvider): Promise<any[]> {
|
|
205
|
+
if (!dataObjectArray || dataObjectArray.length === 0) {
|
|
206
|
+
return dataObjectArray;
|
|
193
207
|
}
|
|
194
|
-
|
|
208
|
+
const mapped: any[] = [];
|
|
209
|
+
for (const element of dataObjectArray) {
|
|
210
|
+
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider));
|
|
211
|
+
}
|
|
212
|
+
return mapped;
|
|
195
213
|
}
|
|
196
214
|
|
|
197
215
|
/**
|
|
@@ -819,12 +837,24 @@ export class ResolverBase {
|
|
|
819
837
|
LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
|
|
820
838
|
}
|
|
821
839
|
|
|
822
|
-
// Process results for GraphQL transport
|
|
840
|
+
// Process results for GraphQL transport.
|
|
841
|
+
//
|
|
842
|
+
// Map onto COPIES, never in place. `FieldMapper.MapFields` renames keys by
|
|
843
|
+
// mutating (`obj[mapped] = obj[k]; delete obj[k]`), and these rows are the
|
|
844
|
+
// provider's own result objects — which the server cache holds BY REFERENCE
|
|
845
|
+
// under a reference-sharing storage provider. Mapping them in place rewrote
|
|
846
|
+
// `__mj_CreatedAt` to the transport alias `_mj__CreatedAt` inside the live
|
|
847
|
+
// cache, and because that cache is process-wide, one GraphQL response made
|
|
848
|
+
// every later read hand back transport-shaped rows that `BaseEntity.SetMany`
|
|
849
|
+
// rejects. `ArrayFilterEncryptedFieldsForAPI` mutates too, so it must also
|
|
850
|
+
// see the copies. (FileResolver already maps a spread copy.)
|
|
851
|
+
//
|
|
852
|
+
// `{ ...r }` is a SHALLOW copy, which is sufficient here because the only
|
|
853
|
+
// post-map mutators rename top-level keys and redact scalar fields; nothing
|
|
854
|
+
// below writes through to a nested value.
|
|
823
855
|
const mapper = new FieldMapper();
|
|
824
856
|
if (result?.Success && result.Results?.length) {
|
|
825
|
-
|
|
826
|
-
mapper.MapFields(r);
|
|
827
|
-
}
|
|
857
|
+
result.Results = result.Results.map(r => mapper.MapFields({ ...r }));
|
|
828
858
|
// Filter encrypted fields before sending to API client
|
|
829
859
|
await this.ArrayFilterEncryptedFieldsForAPI(
|
|
830
860
|
viewInfo.Entity,
|
|
@@ -941,9 +971,9 @@ export class ResolverBase {
|
|
|
941
971
|
for (let i = 0; i < runViewResults.length; i++) {
|
|
942
972
|
const runViewResult = runViewResults[i];
|
|
943
973
|
if (runViewResult?.Success && runViewResult.Results?.length) {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
}
|
|
974
|
+
// Copy-then-map, same reason as the single-view path above: these rows
|
|
975
|
+
// are cache-held references and MapFields renames keys in place.
|
|
976
|
+
runViewResult.Results = runViewResult.Results.map(r => mapper.MapFields({ ...r }));
|
|
947
977
|
// Filter encrypted fields before sending to API client
|
|
948
978
|
// Use the corresponding param's entity name
|
|
949
979
|
const entityName = params[i]?.viewInfo?.Entity;
|
package/src/index.ts
CHANGED
|
@@ -1038,7 +1038,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1038
1038
|
const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
|
|
1039
1039
|
publicUrl: oauthPublicUrl,
|
|
1040
1040
|
successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
|
|
1041
|
-
errorRedirectUrl: `${oauthPublicUrl}/oauth/error
|
|
1041
|
+
errorRedirectUrl: `${oauthPublicUrl}/oauth/error`,
|
|
1042
|
+
// Constrains where a caller-supplied frontendReturnUrl may point (open-redirect guard).
|
|
1043
|
+
allowedFrontendOrigins: configInfo.cors?.allowedOrigins ?? ['*']
|
|
1042
1044
|
});
|
|
1043
1045
|
oauthAuthenticatedRouter = authenticatedRouter;
|
|
1044
1046
|
|
|
@@ -98,9 +98,10 @@ export class ReportResolverExtended extends ResolverBase {
|
|
|
98
98
|
ON
|
|
99
99
|
cd.ConversationID = c.ID
|
|
100
100
|
WHERE
|
|
101
|
-
cd.ID
|
|
101
|
+
cd.ID=@ConversationDetailID`;
|
|
102
102
|
|
|
103
103
|
const request = new mssql.Request(dataSource);
|
|
104
|
+
request.input('ConversationDetailID', mssql.UniqueIdentifier, ConversationDetailID);
|
|
104
105
|
const result = await request.query(sql);
|
|
105
106
|
if (!result || !result.recordset || result.recordset.length === 0) throw new Error('Unable to retrieve converation details');
|
|
106
107
|
const skipData: { title?: string; reportTitle?: string; userExplanation?: string; messages?: unknown[] } = JSON.parse(result.recordset[0].Message);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ReportResolverExtended.CreateReportFromConversationDetailID.
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for a SQL injection fix: ConversationDetailID (a plain GraphQL
|
|
5
|
+
* String arg) used to be interpolated directly into the WHERE clause. It is now bound
|
|
6
|
+
* via mssql's parameterized `request.input(...)`, so a hostile value must never appear
|
|
7
|
+
* spliced into the executed SQL text, and query structure must stay identical regardless
|
|
8
|
+
* of what the value contains.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
|
|
12
|
+
// ─── Hoisted mocks ────────────────────────────────────────────────────────
|
|
13
|
+
const { mockUserCacheUsers, mssqlState } = vi.hoisted(() => ({
|
|
14
|
+
mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
|
|
15
|
+
mssqlState: {
|
|
16
|
+
inputCalls: [] as Array<{ name: string; type: unknown; value: unknown }>,
|
|
17
|
+
queryCalls: [] as string[],
|
|
18
|
+
poolArgs: [] as unknown[],
|
|
19
|
+
recordset: [] as Array<Record<string, unknown>>,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Stub external deps before imports (mirrors resolverBase.rls.test.ts)
|
|
24
|
+
vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
|
|
25
|
+
SQLServerDataProvider: class {},
|
|
26
|
+
UserCache: {
|
|
27
|
+
get Users() { return mockUserCacheUsers; },
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('cloudevents', () => ({
|
|
32
|
+
CloudEvent: class {},
|
|
33
|
+
httpTransport: () => () => undefined,
|
|
34
|
+
emitterFor: () => () => undefined,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('type-graphql', () => ({
|
|
38
|
+
Resolver: () => () => undefined,
|
|
39
|
+
Mutation: () => () => undefined,
|
|
40
|
+
Query: () => () => undefined,
|
|
41
|
+
Subscription: () => () => undefined,
|
|
42
|
+
Ctx: () => () => undefined,
|
|
43
|
+
Arg: () => () => undefined,
|
|
44
|
+
PubSub: () => () => undefined,
|
|
45
|
+
Root: () => () => undefined,
|
|
46
|
+
ObjectType: () => () => undefined,
|
|
47
|
+
InputType: () => () => undefined,
|
|
48
|
+
Field: () => () => undefined,
|
|
49
|
+
FieldResolver: () => () => undefined,
|
|
50
|
+
Int: () => undefined,
|
|
51
|
+
Float: () => undefined,
|
|
52
|
+
registerEnumType: () => undefined,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock('graphql', () => ({
|
|
56
|
+
GraphQLError: class extends Error {
|
|
57
|
+
constructor(msg: string) { super(msg); }
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('mssql', () => {
|
|
62
|
+
const UniqueIdentifier = { __marker: 'UniqueIdentifier' };
|
|
63
|
+
class Request {
|
|
64
|
+
constructor(pool: unknown) {
|
|
65
|
+
mssqlState.poolArgs.push(pool);
|
|
66
|
+
}
|
|
67
|
+
input(name: string, type: unknown, value: unknown) {
|
|
68
|
+
mssqlState.inputCalls.push({ name, type, value });
|
|
69
|
+
}
|
|
70
|
+
async query(sql: string) {
|
|
71
|
+
mssqlState.queryCalls.push(sql);
|
|
72
|
+
return { recordset: mssqlState.recordset };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { default: { Request, UniqueIdentifier }, Request, UniqueIdentifier };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
vi.mock('@memberjunction/data-context', () => {
|
|
79
|
+
class DataContext {
|
|
80
|
+
LoadMetadata = vi.fn(async () => true);
|
|
81
|
+
}
|
|
82
|
+
(DataContext as unknown as { Clone: unknown }).Clone = vi.fn(async () => ({ ID: 'dctx-clone-1' }));
|
|
83
|
+
return { DataContext };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
87
|
+
GetAPIKeyEngine: vi.fn(),
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
vi.mock('@memberjunction/encryption', () => ({
|
|
91
|
+
EncryptionEngine: { Instance: {} },
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
vi.mock('@memberjunction/graphql-dataprovider', () => ({
|
|
95
|
+
FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
vi.mock('../../generic/PubSubManager.js', () => ({
|
|
99
|
+
PubSubManager: class { static Instance = { publish: vi.fn() }; },
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
vi.mock('../../generic/PushStatusResolver.js', () => ({
|
|
103
|
+
PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
|
|
104
|
+
PushStatusNotification: class {},
|
|
105
|
+
PushStatusResolver: class {},
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
vi.mock('../../generic/CacheInvalidationResolver.js', () => ({
|
|
109
|
+
CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
vi.mock('../../generic/RunViewResolver.js', () => ({
|
|
113
|
+
RunViewByIDInput: class {},
|
|
114
|
+
RunViewByNameInput: class {},
|
|
115
|
+
RunDynamicViewInput: class {},
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
vi.mock('../../generic/DeleteOptionsInput.js', () => ({
|
|
119
|
+
DeleteOptionsInput: class {},
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
vi.mock('../../types.js', () => ({
|
|
123
|
+
RunViewGenericParams: class {},
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
vi.mock('@memberjunction/core', async () => {
|
|
127
|
+
const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
|
|
128
|
+
return {
|
|
129
|
+
...actual,
|
|
130
|
+
LogError: vi.fn(),
|
|
131
|
+
LogStatus: vi.fn(),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
vi.mock('@memberjunction/core-entities', () => ({}));
|
|
136
|
+
|
|
137
|
+
// ─── Import after mocks ──────────────────────────────────────────────────
|
|
138
|
+
import { ReportResolverExtended } from '../ReportResolver';
|
|
139
|
+
|
|
140
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function makeReportEntity() {
|
|
143
|
+
return {
|
|
144
|
+
ID: 'report-1',
|
|
145
|
+
Name: '',
|
|
146
|
+
Description: '',
|
|
147
|
+
ConversationID: '',
|
|
148
|
+
ConversationDetailID: '',
|
|
149
|
+
DataContextID: '',
|
|
150
|
+
Configuration: '',
|
|
151
|
+
SharingScope: '',
|
|
152
|
+
UserID: '',
|
|
153
|
+
NewRecord: vi.fn(),
|
|
154
|
+
Save: vi.fn(async () => true),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function makeContext(getEntityObject: () => ReturnType<typeof makeReportEntity>) {
|
|
159
|
+
const md = {
|
|
160
|
+
Entities: [
|
|
161
|
+
{ Name: 'MJ: Conversation Details', SchemaName: 'dbo', BaseView: 'vwConversationDetails' },
|
|
162
|
+
{ Name: 'MJ: Conversations', SchemaName: 'dbo', BaseView: 'vwConversations' },
|
|
163
|
+
],
|
|
164
|
+
GetEntityObject: vi.fn(async () => getEntityObject()),
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
dataSource: { __fakePool: true },
|
|
168
|
+
userPayload: { email: 'test@example.com' }, // no apiKeyHash -> scope check no-ops
|
|
169
|
+
providers: [{ type: 'Read-Write', provider: md }],
|
|
170
|
+
} as unknown as Parameters<ReportResolverExtended['CreateReportFromConversationDetailID']>[1];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe('ReportResolverExtended.CreateReportFromConversationDetailID', () => {
|
|
174
|
+
let resolver: ReportResolverExtended;
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
resolver = new ReportResolverExtended();
|
|
178
|
+
mssqlState.inputCalls.length = 0;
|
|
179
|
+
mssqlState.queryCalls.length = 0;
|
|
180
|
+
mssqlState.poolArgs.length = 0;
|
|
181
|
+
mssqlState.recordset.length = 0;
|
|
182
|
+
mssqlState.recordset.push({
|
|
183
|
+
Message: JSON.stringify({ title: 'Test Report', userExplanation: 'exp' }),
|
|
184
|
+
ConversationID: 'conv-1',
|
|
185
|
+
DataContextID: 'dctx-1',
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
mockUserCacheUsers.length = 0;
|
|
189
|
+
mockUserCacheUsers.push({ Email: 'test@example.com', ID: 'user-1' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('binds ConversationDetailID as a query parameter rather than splicing it into the SQL text', async () => {
|
|
193
|
+
const maliciousID = "1'; DROP TABLE MJ_Reports; --";
|
|
194
|
+
const context = makeContext(makeReportEntity);
|
|
195
|
+
|
|
196
|
+
const result = await resolver.CreateReportFromConversationDetailID(maliciousID, context);
|
|
197
|
+
|
|
198
|
+
expect(result.Success).toBe(true);
|
|
199
|
+
|
|
200
|
+
// The query text must use a bound parameter, never the raw value.
|
|
201
|
+
expect(mssqlState.queryCalls).toHaveLength(1);
|
|
202
|
+
expect(mssqlState.queryCalls[0]).toContain('@ConversationDetailID');
|
|
203
|
+
expect(mssqlState.queryCalls[0]).not.toContain(maliciousID);
|
|
204
|
+
expect(mssqlState.queryCalls[0]).not.toContain('DROP TABLE');
|
|
205
|
+
|
|
206
|
+
// The value must be bound through request.input, not string concatenation.
|
|
207
|
+
expect(mssqlState.inputCalls).toHaveLength(1);
|
|
208
|
+
expect(mssqlState.inputCalls[0].name).toBe('ConversationDetailID');
|
|
209
|
+
expect(mssqlState.inputCalls[0].value).toBe(maliciousID);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('does not alter query structure for values containing quotes, --, or OR 1=1', async () => {
|
|
213
|
+
const hostileID = "abc' OR '1'='1' --";
|
|
214
|
+
const context = makeContext(makeReportEntity);
|
|
215
|
+
|
|
216
|
+
await resolver.CreateReportFromConversationDetailID(hostileID, context);
|
|
217
|
+
|
|
218
|
+
expect(mssqlState.queryCalls[0]).toMatch(/WHERE\s+cd\.ID=@ConversationDetailID\s*$/);
|
|
219
|
+
expect(mssqlState.inputCalls[0].value).toBe(hostileID);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('still succeeds end to end for a normal GUID-shaped value', async () => {
|
|
223
|
+
const normalID = '12345678-1234-1234-1234-123456789012';
|
|
224
|
+
const context = makeContext(makeReportEntity);
|
|
225
|
+
|
|
226
|
+
const result = await resolver.CreateReportFromConversationDetailID(normalID, context);
|
|
227
|
+
|
|
228
|
+
expect(result.Success).toBe(true);
|
|
229
|
+
expect(result.ReportName).toBe('Test Report');
|
|
230
|
+
expect(mssqlState.inputCalls[0].value).toBe(normalID);
|
|
231
|
+
});
|
|
232
|
+
});
|