Skip to content

Commit 1ead16e

Browse files
authored
Merge pull request #267 from PerimeterX/release/v3.7.0
Release/v3.7.0
2 parents 79085ab + 5ff2e27 commit 1ead16e

15 files changed

+600
-85
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [3.7.0] - 2023-01-15
9+
10+
### Added
11+
- support configurable graphql paths
12+
- support multiple queries (Apollo)
13+
14+
### Changed
15+
- full scanning the graphql query to parse the operation name.
16+
17+
### Fixed
18+
- ignore whitespaces at start of operation name
19+
820
## [3.6.0] - 2022-11-17
921

1022
### Added

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[PerimeterX](http://www.perimeterx.com) Shared base for NodeJS enforcers
77
=============================================================
88

9-
> Latest stable version: [v3.6.0](https://www.npmjs.com/package/perimeterx-node-core)
9+
> Latest stable version: [v3.7.0](https://www.npmjs.com/package/perimeterx-node-core)
1010
1111
This is a shared base implementation for PerimeterX Express enforcer and future NodeJS enforcers. For a fully functioning implementation example, see the [Node-Express enforcer](https://github.com/PerimeterX/perimeterx-node-express/) implementation.
1212

lib/models/GraphqlData.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
class GraphqlData {
2-
constructor(graphqlOperationType, graphqlOperationName) {
3-
this.operationType = graphqlOperationType;
4-
this.operationName = graphqlOperationName;
2+
constructor(graphqlOperationType, graphqlOperationName, variables) {
3+
this.type = graphqlOperationType;
4+
this.name = graphqlOperationName;
5+
this.variables = variables;
56
}
67
}
78

lib/pxapi.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const { ModuleMode } = require('./enums/ModuleMode');
77
const PassReason = require('./enums/PassReason');
88
const ScoreEvaluateAction = require('./enums/ScoreEvaluateAction');
99
const S2SErrorReason = require('./enums/S2SErrorReason');
10-
const { CI_USERNAME_FIELD, CI_PASSWORD_FIELD, CI_VERSION_FIELD, CI_SSO_STEP_FIELD, GQL_OPERATION_TYPE_FIELD, GQL_OPERATION_NAME_FIELD } = require('./utils/constants');
10+
const { CI_USERNAME_FIELD, CI_PASSWORD_FIELD, CI_VERSION_FIELD, CI_SSO_STEP_FIELD,
11+
GQL_OPERATIONS_FIELD
12+
} = require('./utils/constants');
1113
const { CIVersion } = require('./enums/CIVersion');
1214

1315
exports.evalByServerCall = evalByServerCall;
@@ -60,8 +62,7 @@ function buildRequestData(ctx, config) {
6062
};
6163

6264
if (ctx.graphqlData) {
63-
data.additional[GQL_OPERATION_TYPE_FIELD] = ctx.graphqlData.operationType;
64-
data.additional[GQL_OPERATION_NAME_FIELD] = ctx.graphqlData.operationName;
65+
data.additional[GQL_OPERATIONS_FIELD] = ctx.graphqlData;
6566
}
6667
if (ctx.serverInfoRegion) {
6768
data.additional['server_info_region'] = ctx.serverInfoRegion;

lib/pxclient.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ const {
99
CI_SSO_STEP_FIELD,
1010
CI_RAW_USERNAME_FIELD,
1111
CI_CREDENTIALS_COMPROMISED_FIELD,
12-
GQL_OPERATION_TYPE_FIELD,
13-
GQL_OPERATION_NAME_FIELD
12+
GQL_OPERATIONS_FIELD
1413
} = require('./utils/constants');
1514

1615
class PxClient {
@@ -63,8 +62,7 @@ class PxClient {
6362
}
6463

6564
if (ctx.graphqlData) {
66-
details[GQL_OPERATION_TYPE_FIELD] = ctx.graphqlData.operationType;
67-
details[GQL_OPERATION_NAME_FIELD] = ctx.graphqlData.operationName;
65+
details[GQL_OPERATIONS_FIELD] = ctx.graphqlData;
6866
}
6967
}
7068

lib/pxconfig.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class PxConfig {
8181
['COMPROMISED_CREDENTIALS_HEADER', 'px_compromised_credentials_header'],
8282
['ENABLE_ADDITIONAL_S2S_ACTIVITY_HEADER', 'px_additional_s2s_activity_header_enabled'],
8383
['SENSITIVE_GRAPHQL_OPERATION_TYPES', 'px_sensitive_graphql_operation_types'],
84+
['GRAPHQL_ROUTES', 'px_graphql_routes'],
8485
['SENSITIVE_GRAPHQL_OPERATION_NAMES', 'px_sensitive_graphql_operation_names'],
8586
['SEND_RAW_USERNAME_ON_ADDITIONAL_S2S_ACTIVITY', 'px_send_raw_username_on_additional_s2s_activity'],
8687
['AUTOMATIC_ADDITIONAL_S2S_ACTIVITY_ENABLED', 'px_automatic_additional_s2s_activity_enabled'],
@@ -335,6 +336,7 @@ function pxDefaultConfig() {
335336
LOGIN_SUCCESSFUL_BODY_REGEX: '',
336337
LOGIN_SUCCESSFUL_CUSTOM_CALLBACK: null,
337338
MODIFY_CONTEXT: null,
339+
GRAPHQL_ROUTES: ['^/graphql$']
338340
};
339341
}
340342

@@ -396,6 +398,7 @@ const allowedConfigKeys = [
396398
'px_login_successful_body_regex',
397399
'px_login_successful_custom_callback',
398400
'px_modify_context',
401+
'px_graphql_routes'
399402
];
400403

401404
module.exports = PxConfig;

lib/pxcontext.js

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,32 @@ class PxContext {
5656
}
5757
});
5858
}
59-
if (this.uri.includes('graphql')) {
59+
if (pxUtil.isGraphql(req, config)) {
6060
config.logger.debug('Graphql route detected');
61-
this.graphqlData = pxUtil.getGraphqlData(req);
62-
this.sensitiveGraphqlOperation = this.isSensitiveGraphqlOperation(config);
61+
this.graphqlData = this.getGraphqlDataFromBody(req.body).filter(x => x).map(
62+
operation => operation && {
63+
...operation,
64+
sensitive: pxUtil.isSensitiveGraphqlOperation(operation, config),
65+
});
66+
this.sensitiveGraphqlOperation = this.graphqlData.some(operation => operation && operation.sensitive);
6367
}
6468
if (process.env.AWS_REGION) {
6569
this.serverInfoRegion = process.env.AWS_REGION;
6670
}
6771
}
6872

73+
getGraphqlDataFromBody(body) {
74+
let jsonBody = null;
75+
if (typeof body === 'string') {
76+
jsonBody = pxUtil.tryOrNull(() => JSON.parse(body));
77+
} else if (typeof body === 'object') {
78+
jsonBody = body;
79+
}
80+
return Array.isArray(jsonBody) ?
81+
jsonBody.map(pxUtil.getGraphqlData) :
82+
[pxUtil.getGraphqlData(jsonBody)];
83+
}
84+
6985
getCookie() {
7086
return this.cookies['_px3'] ? this.cookies['_px3'] : this.cookies['_px'];
7187
}
@@ -81,15 +97,6 @@ class PxContext {
8197
return Array.isArray(routes) ? routes.some((route) => this.verifyRoute(route, uri)) : false;
8298
}
8399

84-
isSensitiveGraphqlOperation(config) {
85-
if (!this.graphqlData) {
86-
return false;
87-
}
88-
const { operationType, operationName } = this.graphqlData;
89-
return config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(operationType)
90-
|| config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(operationName);
91-
}
92-
93100
verifyRoute(pattern, uri) {
94101
if (pattern instanceof RegExp && uri.match(pattern)) {
95102
return true;

lib/pxcookie.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function evalCookie(ctx, config) {
9494
}
9595

9696
if (ctx.sensitiveGraphqlOperation) {
97-
config.logger.debug(`Sensitive graphql operation, sending Risk API. operation type: ${ctx.graphqlData.operationType}, operation name: ${ctx.graphqlData.operationName}`);
97+
config.logger.debug(`Sensitive graphql operation, sending Risk API. operations: ${JSON.stringify(ctx.graphqlData)}`);
9898
ctx.s2sCallReason = 'sensitive_route';
9999
return ScoreEvaluateAction.SENSITIVE_ROUTE;
100100
}

lib/pxutil.js

Lines changed: 95 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const fs = require('fs');
55
const crypto = require('crypto');
66

77
const { ModuleMode } = require('./enums/ModuleMode');
8-
const { GraphqlOperationType } = require('./enums/GraphqlOperationType');
98
const { GraphqlData } = require('./models/GraphqlData');
109
const { EMAIL_ADDRESS_REGEX, HASH_ALGORITHM } = require('./utils/constants');
1110

@@ -267,64 +266,121 @@ function getTokenObject(cookie, delimiter = ':') {
267266
return { key: '_px3', value: cookie };
268267
}
269268

270-
function getGraphqlData(req) {
271-
const isGraphqlPath = req.path.includes('graphql') || req.originalUrl.includes('graphql');
272-
if (!isGraphqlPath || !req.body) {
273-
return null;
269+
function isGraphql(req, config) {
270+
if (req.method.toLowerCase() !== 'post') {
271+
return false;
274272
}
275273

276-
const { body, query } = getGraphqlBodyAndQuery(req);
277-
if (!body || !query) {
278-
return null;
274+
const routes = config['GRAPHQL_ROUTES'];
275+
if (!Array.isArray(routes)) {
276+
config.logger.error('Invalid configuration px_graphql_routes');
277+
return false;
278+
}
279+
try {
280+
return routes.some(r => new RegExp(r).test(req.baseUrl || '' + req.path));
281+
} catch (e) {
282+
config.logger.error(`Failed to process graphql routes. exception: ${e}`);
283+
return false;
279284
}
280-
281-
const operationType = extractGraphqlOperationType(query);
282-
const operationName = extractGraphqlOperationName(query, body['operationName']);
283-
return new GraphqlData(operationType, operationName);
284285
}
285286

286-
function getGraphqlBodyAndQuery(req) {
287-
let body = {};
288-
let query = '';
289-
290-
try {
291-
body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
292-
query = body['query'];
293-
} catch (err) {
294-
// json parse error
287+
// query: string (not null)
288+
// output: Record [ OperationName -> OperationType ]
289+
function parseGraphqlBody(query) {
290+
const pattern = /\s*(query|mutation|subscription)\s+(\w+)/gm;
291+
let match;
292+
const ret = {};
293+
while ((match = pattern.exec(query)) !== null) {
294+
const operationName = match[2];
295+
const operationType = match[1];
296+
297+
// if two operations have the same name, the query is illegal.
298+
if (ret[operationName]) {
299+
return null;
300+
} else {
301+
ret[operationName] = operationType;
302+
}
295303
}
296304

297-
return { body, query };
305+
return ret;
298306
}
299307

300-
function extractGraphqlOperationType(query) {
301-
const isGraphqlQueryShorthand = query[0] === '{';
302-
if (isGraphqlQueryShorthand) {
303-
return GraphqlOperationType.QUERY;
308+
// graphqlData: GraphqlData
309+
// output: boolean
310+
function isSensitiveGraphqlOperation(graphqlData, config) {
311+
if (!graphqlData) {
312+
return false;
313+
} else {
314+
return (config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(graphqlData.type) ||
315+
config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(graphqlData.name));
304316
}
305-
306-
const queryArray = query.split(/[^A-Za-z0-9_]/);
307-
return isValidGraphqlOperationType(queryArray[0]) ? queryArray[0] : GraphqlOperationType.QUERY;
308317
}
309318

310-
function extractGraphqlOperationName(query, operationName) {
311-
if (operationName) {
312-
return operationName;
319+
// graphqlBodyObject: {query: string?, operationName: string?, variables: any[]?}
320+
// output: GraphqlData?
321+
function getGraphqlData(graphqlBodyObject) {
322+
if (!graphqlBodyObject || !graphqlBodyObject.query) {
323+
return null;
324+
}
325+
326+
const parsedData = parseGraphqlBody(graphqlBodyObject.query);
327+
if (!parsedData) {
328+
return null;
329+
}
330+
331+
const selectedOperationName = graphqlBodyObject['operationName'] ||
332+
(Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]);
333+
334+
if (!selectedOperationName || !parsedData[selectedOperationName]) {
335+
return null;
313336
}
314337

315-
const queryArray = query.split(/[^A-Za-z0-9_]/);
316-
return isValidGraphqlOperationType(queryArray[0]) ? queryArray[1] : queryArray[0];
338+
const variables = extractVariables(graphqlBodyObject.variables);
339+
340+
return new GraphqlData(parsedData[selectedOperationName],
341+
selectedOperationName,
342+
variables,
343+
);
317344
}
318345

319-
function isValidGraphqlOperationType(operationType) {
320-
return Object.values(GraphqlOperationType).includes(operationType);
346+
// input: object representing variables
347+
// output: list of keys recursively like property file.
348+
function extractVariables(variables) {
349+
function go(variables, prefix) {
350+
return Object.entries(variables).reduce((total, [key, value]) => {
351+
if (!value || typeof value !== 'object' || Object.keys(value).length === 0) {
352+
total.push(prefix + key);
353+
return total;
354+
} else {
355+
return total.concat(go(value, prefix + key + '.'));
356+
}
357+
}, []);
358+
}
359+
360+
if (!variables || typeof variables !== 'object') {
361+
return [];
362+
} else {
363+
return go(variables, '');
364+
}
321365
}
322366

323367
function isEmailAddress(str) {
324368
return EMAIL_ADDRESS_REGEX.test(str);
325369
}
326370

371+
function tryOrNull(fn, exceptionHandler) {
372+
try {
373+
return fn();
374+
} catch (e) {
375+
if (exceptionHandler) {
376+
exceptionHandler(e);
377+
}
378+
return null;
379+
}
380+
}
381+
327382
module.exports = {
383+
isSensitiveGraphqlOperation,
328384
formatHeaders,
329385
filterSensitiveHeaders,
330386
checkForStatic,
@@ -343,5 +399,7 @@ module.exports = {
343399
isReqInMonitorMode,
344400
getTokenObject,
345401
getGraphqlData,
346-
isEmailAddress
402+
isEmailAddress,
403+
isGraphql,
404+
tryOrNull,
347405
};

lib/utils/constants.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ const CI_RAW_USERNAME_FIELD = 'raw_username';
2323
const CI_SSO_STEP_FIELD = 'sso_step';
2424
const CI_CREDENTIALS_COMPROMISED_FIELD = 'credentials_compromised';
2525

26-
const GQL_OPERATION_TYPE_FIELD = 'graphql_operation_type';
27-
const GQL_OPERATION_NAME_FIELD = 'graphql_operation_name';
26+
const GQL_OPERATIONS_FIELD = 'graphql_operations';
2827
const EMAIL_ADDRESS_REGEX = /^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
2928
const HASH_ALGORITHM = { SHA256: 'sha256' };
3029

@@ -48,8 +47,7 @@ module.exports = {
4847
CI_RAW_USERNAME_FIELD,
4948
CI_SSO_STEP_FIELD,
5049
CI_CREDENTIALS_COMPROMISED_FIELD,
51-
GQL_OPERATION_TYPE_FIELD,
52-
GQL_OPERATION_NAME_FIELD,
50+
GQL_OPERATIONS_FIELD,
5351
EMAIL_ADDRESS_REGEX,
5452
HASH_ALGORITHM
5553
};

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "perimeterx-node-core",
3-
"version": "3.6.0",
3+
"version": "3.7.0",
44
"description": "PerimeterX NodeJS shared core for various applications to monitor and block traffic according to PerimeterX risk score",
55
"main": "index.js",
66
"scripts": {

0 commit comments

Comments
 (0)