Skip to content

Commit

Permalink
Merge pull request #267 from PerimeterX/release/v3.7.0
Browse files Browse the repository at this point in the history
Release/v3.7.0
  • Loading branch information
guyeisenbach authored Jan 15, 2023
2 parents 79085ab + 5ff2e27 commit 1ead16e
Show file tree
Hide file tree
Showing 15 changed files with 600 additions and 85 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [3.7.0] - 2023-01-15

### Added
- support configurable graphql paths
- support multiple queries (Apollo)

### Changed
- full scanning the graphql query to parse the operation name.

### Fixed
- ignore whitespaces at start of operation name

## [3.6.0] - 2022-11-17

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[PerimeterX](http://www.perimeterx.com) Shared base for NodeJS enforcers
=============================================================

> Latest stable version: [v3.6.0](https://www.npmjs.com/package/perimeterx-node-core)
> Latest stable version: [v3.7.0](https://www.npmjs.com/package/perimeterx-node-core)
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.

Expand Down
7 changes: 4 additions & 3 deletions lib/models/GraphqlData.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
class GraphqlData {
constructor(graphqlOperationType, graphqlOperationName) {
this.operationType = graphqlOperationType;
this.operationName = graphqlOperationName;
constructor(graphqlOperationType, graphqlOperationName, variables) {
this.type = graphqlOperationType;
this.name = graphqlOperationName;
this.variables = variables;
}
}

Expand Down
7 changes: 4 additions & 3 deletions lib/pxapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ const { ModuleMode } = require('./enums/ModuleMode');
const PassReason = require('./enums/PassReason');
const ScoreEvaluateAction = require('./enums/ScoreEvaluateAction');
const S2SErrorReason = require('./enums/S2SErrorReason');
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');
const { CI_USERNAME_FIELD, CI_PASSWORD_FIELD, CI_VERSION_FIELD, CI_SSO_STEP_FIELD,
GQL_OPERATIONS_FIELD
} = require('./utils/constants');
const { CIVersion } = require('./enums/CIVersion');

exports.evalByServerCall = evalByServerCall;
Expand Down Expand Up @@ -60,8 +62,7 @@ function buildRequestData(ctx, config) {
};

if (ctx.graphqlData) {
data.additional[GQL_OPERATION_TYPE_FIELD] = ctx.graphqlData.operationType;
data.additional[GQL_OPERATION_NAME_FIELD] = ctx.graphqlData.operationName;
data.additional[GQL_OPERATIONS_FIELD] = ctx.graphqlData;
}
if (ctx.serverInfoRegion) {
data.additional['server_info_region'] = ctx.serverInfoRegion;
Expand Down
6 changes: 2 additions & 4 deletions lib/pxclient.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ const {
CI_SSO_STEP_FIELD,
CI_RAW_USERNAME_FIELD,
CI_CREDENTIALS_COMPROMISED_FIELD,
GQL_OPERATION_TYPE_FIELD,
GQL_OPERATION_NAME_FIELD
GQL_OPERATIONS_FIELD
} = require('./utils/constants');

class PxClient {
Expand Down Expand Up @@ -63,8 +62,7 @@ class PxClient {
}

if (ctx.graphqlData) {
details[GQL_OPERATION_TYPE_FIELD] = ctx.graphqlData.operationType;
details[GQL_OPERATION_NAME_FIELD] = ctx.graphqlData.operationName;
details[GQL_OPERATIONS_FIELD] = ctx.graphqlData;
}
}

Expand Down
3 changes: 3 additions & 0 deletions lib/pxconfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class PxConfig {
['COMPROMISED_CREDENTIALS_HEADER', 'px_compromised_credentials_header'],
['ENABLE_ADDITIONAL_S2S_ACTIVITY_HEADER', 'px_additional_s2s_activity_header_enabled'],
['SENSITIVE_GRAPHQL_OPERATION_TYPES', 'px_sensitive_graphql_operation_types'],
['GRAPHQL_ROUTES', 'px_graphql_routes'],
['SENSITIVE_GRAPHQL_OPERATION_NAMES', 'px_sensitive_graphql_operation_names'],
['SEND_RAW_USERNAME_ON_ADDITIONAL_S2S_ACTIVITY', 'px_send_raw_username_on_additional_s2s_activity'],
['AUTOMATIC_ADDITIONAL_S2S_ACTIVITY_ENABLED', 'px_automatic_additional_s2s_activity_enabled'],
Expand Down Expand Up @@ -335,6 +336,7 @@ function pxDefaultConfig() {
LOGIN_SUCCESSFUL_BODY_REGEX: '',
LOGIN_SUCCESSFUL_CUSTOM_CALLBACK: null,
MODIFY_CONTEXT: null,
GRAPHQL_ROUTES: ['^/graphql$']
};
}

Expand Down Expand Up @@ -396,6 +398,7 @@ const allowedConfigKeys = [
'px_login_successful_body_regex',
'px_login_successful_custom_callback',
'px_modify_context',
'px_graphql_routes'
];

module.exports = PxConfig;
31 changes: 19 additions & 12 deletions lib/pxcontext.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,32 @@ class PxContext {
}
});
}
if (this.uri.includes('graphql')) {
if (pxUtil.isGraphql(req, config)) {
config.logger.debug('Graphql route detected');
this.graphqlData = pxUtil.getGraphqlData(req);
this.sensitiveGraphqlOperation = this.isSensitiveGraphqlOperation(config);
this.graphqlData = this.getGraphqlDataFromBody(req.body).filter(x => x).map(
operation => operation && {
...operation,
sensitive: pxUtil.isSensitiveGraphqlOperation(operation, config),
});
this.sensitiveGraphqlOperation = this.graphqlData.some(operation => operation && operation.sensitive);
}
if (process.env.AWS_REGION) {
this.serverInfoRegion = process.env.AWS_REGION;
}
}

getGraphqlDataFromBody(body) {
let jsonBody = null;
if (typeof body === 'string') {
jsonBody = pxUtil.tryOrNull(() => JSON.parse(body));
} else if (typeof body === 'object') {
jsonBody = body;
}
return Array.isArray(jsonBody) ?
jsonBody.map(pxUtil.getGraphqlData) :
[pxUtil.getGraphqlData(jsonBody)];
}

getCookie() {
return this.cookies['_px3'] ? this.cookies['_px3'] : this.cookies['_px'];
}
Expand All @@ -81,15 +97,6 @@ class PxContext {
return Array.isArray(routes) ? routes.some((route) => this.verifyRoute(route, uri)) : false;
}

isSensitiveGraphqlOperation(config) {
if (!this.graphqlData) {
return false;
}
const { operationType, operationName } = this.graphqlData;
return config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(operationType)
|| config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(operationName);
}

verifyRoute(pattern, uri) {
if (pattern instanceof RegExp && uri.match(pattern)) {
return true;
Expand Down
2 changes: 1 addition & 1 deletion lib/pxcookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function evalCookie(ctx, config) {
}

if (ctx.sensitiveGraphqlOperation) {
config.logger.debug(`Sensitive graphql operation, sending Risk API. operation type: ${ctx.graphqlData.operationType}, operation name: ${ctx.graphqlData.operationName}`);
config.logger.debug(`Sensitive graphql operation, sending Risk API. operations: ${JSON.stringify(ctx.graphqlData)}`);
ctx.s2sCallReason = 'sensitive_route';
return ScoreEvaluateAction.SENSITIVE_ROUTE;
}
Expand Down
132 changes: 95 additions & 37 deletions lib/pxutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const fs = require('fs');
const crypto = require('crypto');

const { ModuleMode } = require('./enums/ModuleMode');
const { GraphqlOperationType } = require('./enums/GraphqlOperationType');
const { GraphqlData } = require('./models/GraphqlData');
const { EMAIL_ADDRESS_REGEX, HASH_ALGORITHM } = require('./utils/constants');

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

function getGraphqlData(req) {
const isGraphqlPath = req.path.includes('graphql') || req.originalUrl.includes('graphql');
if (!isGraphqlPath || !req.body) {
return null;
function isGraphql(req, config) {
if (req.method.toLowerCase() !== 'post') {
return false;
}

const { body, query } = getGraphqlBodyAndQuery(req);
if (!body || !query) {
return null;
const routes = config['GRAPHQL_ROUTES'];
if (!Array.isArray(routes)) {
config.logger.error('Invalid configuration px_graphql_routes');
return false;
}
try {
return routes.some(r => new RegExp(r).test(req.baseUrl || '' + req.path));
} catch (e) {
config.logger.error(`Failed to process graphql routes. exception: ${e}`);
return false;
}

const operationType = extractGraphqlOperationType(query);
const operationName = extractGraphqlOperationName(query, body['operationName']);
return new GraphqlData(operationType, operationName);
}

function getGraphqlBodyAndQuery(req) {
let body = {};
let query = '';

try {
body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
query = body['query'];
} catch (err) {
// json parse error
// query: string (not null)
// output: Record [ OperationName -> OperationType ]
function parseGraphqlBody(query) {
const pattern = /\s*(query|mutation|subscription)\s+(\w+)/gm;
let match;
const ret = {};
while ((match = pattern.exec(query)) !== null) {
const operationName = match[2];
const operationType = match[1];

// if two operations have the same name, the query is illegal.
if (ret[operationName]) {
return null;
} else {
ret[operationName] = operationType;
}
}

return { body, query };
return ret;
}

function extractGraphqlOperationType(query) {
const isGraphqlQueryShorthand = query[0] === '{';
if (isGraphqlQueryShorthand) {
return GraphqlOperationType.QUERY;
// graphqlData: GraphqlData
// output: boolean
function isSensitiveGraphqlOperation(graphqlData, config) {
if (!graphqlData) {
return false;
} else {
return (config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(graphqlData.type) ||
config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(graphqlData.name));
}

const queryArray = query.split(/[^A-Za-z0-9_]/);
return isValidGraphqlOperationType(queryArray[0]) ? queryArray[0] : GraphqlOperationType.QUERY;
}

function extractGraphqlOperationName(query, operationName) {
if (operationName) {
return operationName;
// graphqlBodyObject: {query: string?, operationName: string?, variables: any[]?}
// output: GraphqlData?
function getGraphqlData(graphqlBodyObject) {
if (!graphqlBodyObject || !graphqlBodyObject.query) {
return null;
}

const parsedData = parseGraphqlBody(graphqlBodyObject.query);
if (!parsedData) {
return null;
}

const selectedOperationName = graphqlBodyObject['operationName'] ||
(Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]);

if (!selectedOperationName || !parsedData[selectedOperationName]) {
return null;
}

const queryArray = query.split(/[^A-Za-z0-9_]/);
return isValidGraphqlOperationType(queryArray[0]) ? queryArray[1] : queryArray[0];
const variables = extractVariables(graphqlBodyObject.variables);

return new GraphqlData(parsedData[selectedOperationName],
selectedOperationName,
variables,
);
}

function isValidGraphqlOperationType(operationType) {
return Object.values(GraphqlOperationType).includes(operationType);
// input: object representing variables
// output: list of keys recursively like property file.
function extractVariables(variables) {
function go(variables, prefix) {
return Object.entries(variables).reduce((total, [key, value]) => {
if (!value || typeof value !== 'object' || Object.keys(value).length === 0) {
total.push(prefix + key);
return total;
} else {
return total.concat(go(value, prefix + key + '.'));
}
}, []);
}

if (!variables || typeof variables !== 'object') {
return [];
} else {
return go(variables, '');
}
}

function isEmailAddress(str) {
return EMAIL_ADDRESS_REGEX.test(str);
}

function tryOrNull(fn, exceptionHandler) {
try {
return fn();
} catch (e) {
if (exceptionHandler) {
exceptionHandler(e);
}
return null;
}
}

module.exports = {
isSensitiveGraphqlOperation,
formatHeaders,
filterSensitiveHeaders,
checkForStatic,
Expand All @@ -343,5 +399,7 @@ module.exports = {
isReqInMonitorMode,
getTokenObject,
getGraphqlData,
isEmailAddress
isEmailAddress,
isGraphql,
tryOrNull,
};
6 changes: 2 additions & 4 deletions lib/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ const CI_RAW_USERNAME_FIELD = 'raw_username';
const CI_SSO_STEP_FIELD = 'sso_step';
const CI_CREDENTIALS_COMPROMISED_FIELD = 'credentials_compromised';

const GQL_OPERATION_TYPE_FIELD = 'graphql_operation_type';
const GQL_OPERATION_NAME_FIELD = 'graphql_operation_name';
const GQL_OPERATIONS_FIELD = 'graphql_operations';
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])?)$/;
const HASH_ALGORITHM = { SHA256: 'sha256' };

Expand All @@ -48,8 +47,7 @@ module.exports = {
CI_RAW_USERNAME_FIELD,
CI_SSO_STEP_FIELD,
CI_CREDENTIALS_COMPROMISED_FIELD,
GQL_OPERATION_TYPE_FIELD,
GQL_OPERATION_NAME_FIELD,
GQL_OPERATIONS_FIELD,
EMAIL_ADDRESS_REGEX,
HASH_ALGORITHM
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "perimeterx-node-core",
"version": "3.6.0",
"version": "3.7.0",
"description": "PerimeterX NodeJS shared core for various applications to monitor and block traffic according to PerimeterX risk score",
"main": "index.js",
"scripts": {
Expand Down
Loading

0 comments on commit 1ead16e

Please sign in to comment.