diff --git a/CHANGELOG.md b/CHANGELOG.md index 21efbfd..8257463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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.8.0] - 2023-01-25 + +### Added +- Support User Identifiers: CTS and JWT. + ## [3.7.0] - 2023-01-15 ### Added diff --git a/README.md b/README.md index 087b2ad..b13f83d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [PerimeterX](http://www.perimeterx.com) Shared base for NodeJS enforcers ============================================================= -> Latest stable version: [v3.7.0](https://www.npmjs.com/package/perimeterx-node-core) +> Latest stable version: [v3.8.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. diff --git a/lib/pxapi.js b/lib/pxapi.js index 91e5355..0c71d7f 100644 --- a/lib/pxapi.js +++ b/lib/pxapi.js @@ -7,8 +7,16 @@ 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_OPERATIONS_FIELD + +const { + CI_USERNAME_FIELD, + CI_PASSWORD_FIELD, + CI_VERSION_FIELD, + CI_SSO_STEP_FIELD, + GQL_OPERATIONS_FIELD, + JWT_ADDITIONAL_FIELDS_FIELD_NAME, + APP_USER_ID_FIELD_NAME, + CROSS_TAB_SESSION, } = require('./utils/constants'); const { CIVersion } = require('./enums/CIVersion'); @@ -78,11 +86,31 @@ function buildRequestData(ctx, config) { } } + if (ctx.jwt) { + const { userID, additionalFields } = ctx.jwt; + + if (userID) { + data.additional[APP_USER_ID_FIELD_NAME] = userID; + } + + if (additionalFields) { + data.additional[JWT_ADDITIONAL_FIELDS_FIELD_NAME] = additionalFields; + } + } + + if (ctx.cts) { + data.additional[CROSS_TAB_SESSION] = ctx.cts; + } + if (ctx.s2sCallReason === 'cookie_decryption_failed') { data.additional.px_orig_cookie = ctx.getCookie(); //No need strigify, already a string } - if (ctx.s2sCallReason === 'cookie_expired' || ctx.s2sCallReason === 'cookie_validation_failed' || ctx.s2sCallReason === 'sensitive_route') { + if ( + ctx.s2sCallReason === 'cookie_expired' || + ctx.s2sCallReason === 'cookie_validation_failed' || + ctx.s2sCallReason === 'sensitive_route' + ) { data.additional.px_cookie = JSON.stringify(ctx.decodedCookie); } diff --git a/lib/pxclient.js b/lib/pxclient.js index 09780d6..36ff995 100644 --- a/lib/pxclient.js +++ b/lib/pxclient.js @@ -9,7 +9,10 @@ const { CI_SSO_STEP_FIELD, CI_RAW_USERNAME_FIELD, CI_CREDENTIALS_COMPROMISED_FIELD, - GQL_OPERATIONS_FIELD + GQL_OPERATIONS_FIELD, + APP_USER_ID_FIELD_NAME, + JWT_ADDITIONAL_FIELDS_FIELD_NAME, + CROSS_TAB_SESSION, } = require('./utils/constants'); class PxClient { @@ -64,6 +67,22 @@ class PxClient { if (ctx.graphqlData) { details[GQL_OPERATIONS_FIELD] = ctx.graphqlData; } + + if (ctx.jwt) { + const { userID, additionalFields } = ctx.jwt; + + if (userID) { + details[APP_USER_ID_FIELD_NAME] = userID; + } + + if (additionalFields) { + details[JWT_ADDITIONAL_FIELDS_FIELD_NAME] = additionalFields; + } + } + + if (ctx.cts) { + details[CROSS_TAB_SESSION] = ctx.cts; + } } /** @@ -85,11 +104,11 @@ class PxClient { sendEnforcerTelemetry(updateReason, config) { const details = { - 'enforcer_configs': pxUtil.filterConfig(config), - 'node_name': os.hostname(), - 'os_name': os.platform(), - 'update_reason': updateReason, - 'module_version': config.MODULE_VERSION + enforcer_configs: pxUtil.filterConfig(config), + node_name: os.hostname(), + os_name: os.platform(), + update_reason: updateReason, + module_version: config.MODULE_VERSION, }; const pxData = {}; @@ -119,9 +138,9 @@ class PxClient { createHeaders(config, additionalHeaders = {}) { return { - 'Authorization': 'Bearer ' + config.AUTH_TOKEN, + Authorization: 'Bearer ' + config.AUTH_TOKEN, 'Content-Type': 'application/json', - ...additionalHeaders + ...additionalHeaders, }; } @@ -135,7 +154,7 @@ class PxClient { [CI_VERSION_FIELD]: loginCredentials && loginCredentials.version, [CI_RAW_USERNAME_FIELD]: loginCredentials && loginCredentials.rawUsername, [CI_SSO_STEP_FIELD]: loginCredentials && loginCredentials.ssoStep, - ...additionalDetails + ...additionalDetails, }; if (!config.SEND_RAW_USERNAME_ON_ADDITIONAL_S2S_ACTIVITY || !details.credentials_compromised) { diff --git a/lib/pxconfig.js b/lib/pxconfig.js index 04efd69..7c5f085 100644 --- a/lib/pxconfig.js +++ b/lib/pxconfig.js @@ -92,6 +92,12 @@ class PxConfig { ['LOGIN_SUCCESSFUL_BODY_REGEX', 'px_login_successful_body_regex'], ['LOGIN_SUCCESSFUL_CUSTOM_CALLBACK', 'px_login_successful_custom_callback'], ['MODIFY_CONTEXT', 'px_modify_context'], + ['JWT_COOKIE_NAME', 'px_jwt_cookie_name'], + ['JWT_COOKIE_USER_ID_FIELD_NAME', 'px_jwt_cookie_user_id_field_name'], + ['JWT_COOKIE_ADDITIONAL_FIELD_NAMES', 'px_jwt_cookie_additional_field_names'], + ['JWT_HEADER_NAME', 'px_jwt_header_name'], + ['JWT_HEADER_USER_ID_FIELD_NAME', 'px_jwt_header_user_id_field_name'], + ['JWT_HEADER_ADDITIONAL_FIELD_NAMES', 'px_jwt_header_additional_field_names'], ]; configKeyMapping.forEach(([targetKey, sourceKey]) => { @@ -336,7 +342,13 @@ function pxDefaultConfig() { LOGIN_SUCCESSFUL_BODY_REGEX: '', LOGIN_SUCCESSFUL_CUSTOM_CALLBACK: null, MODIFY_CONTEXT: null, - GRAPHQL_ROUTES: ['^/graphql$'] + GRAPHQL_ROUTES: ['^/graphql$'], + JWT_COOKIE_NAME: '', + JWT_COOKIE_USER_ID_FIELD_NAME: '', + JWT_COOKIE_ADDITIONAL_FIELD_NAMES: [], + JWT_HEADER_NAME: '', + JWT_HEADER_USER_ID_FIELD_NAME: '', + JWT_HEADER_ADDITIONAL_FIELD_NAMES: [], }; } @@ -398,7 +410,13 @@ const allowedConfigKeys = [ 'px_login_successful_body_regex', 'px_login_successful_custom_callback', 'px_modify_context', - 'px_graphql_routes' + 'px_graphql_routes', + 'px_jwt_cookie_name', + 'px_jwt_cookie_user_id_field_name', + 'px_jwt_cookie_additional_field_names', + 'px_jwt_header_name', + 'px_jwt_header_user_id_field_name', + 'px_jwt_header_additional_field_names', ]; module.exports = PxConfig; diff --git a/lib/pxcontext.js b/lib/pxcontext.js index e9df65c..672e866 100644 --- a/lib/pxcontext.js +++ b/lib/pxcontext.js @@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid'); const { CookieOrigin } = require('./enums/CookieOrigin'); const pxUtil = require('./pxutil'); +const pxJWT = require('./pxjwt'); class PxContext { constructor(config, req, additionalFields) { @@ -30,6 +31,7 @@ class PxContext { this.cookieOrigin = CookieOrigin.COOKIE; this.additionalFields = additionalFields || {}; this.signedFields = [this.userAgent]; + const mobileHeader = this.headers[mobileSdkHeader]; if (mobileHeader !== undefined) { this.signedFields = null; @@ -51,6 +53,8 @@ class PxContext { } else if ((key === '_pxvid' || key === 'pxvid') && vidRegex.test(cookies[key])) { this.vid = cookies[key]; this.vidSource = 'vid_cookie'; + } else if (key === 'pxcts') { + this.cts = cookies[key]; } else if (key.match(/^_px.+$/)) { this.cookies[key] = cookies[key]; } @@ -58,16 +62,27 @@ class PxContext { } if (pxUtil.isGraphql(req, config)) { config.logger.debug('Graphql route detected'); - 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); + 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; } + + if (config.JWT_COOKIE_NAME || config.JWT_HEADER_NAME) { + const token = req.cookies[config.JWT_COOKIE_NAME] || req.headers[config.JWT_HEADER_NAME]; + if (token) { + this.jwt = pxJWT.extractJWTData(config, token); + } + } } getGraphqlDataFromBody(body) { @@ -77,9 +92,7 @@ class PxContext { } else if (typeof body === 'object') { jsonBody = body; } - return Array.isArray(jsonBody) ? - jsonBody.map(pxUtil.getGraphqlData) : - [pxUtil.getGraphqlData(jsonBody)]; + return Array.isArray(jsonBody) ? jsonBody.map(pxUtil.getGraphqlData) : [pxUtil.getGraphqlData(jsonBody)]; } getCookie() { diff --git a/lib/pxjwt.js b/lib/pxjwt.js new file mode 100644 index 0000000..b9b675e --- /dev/null +++ b/lib/pxjwt.js @@ -0,0 +1,59 @@ +const { TOKEN_SEPARATOR } = require('./utils/constants'); + +function getJWTPayload(pxConfig, token) { + try { + const encodedPayload = token.split(TOKEN_SEPARATOR)[1]; + if (encodedPayload) { + const base64Payload = encodedPayload.replace('-', '+').replace('_', '/'); + const payload = Buffer.from(base64Payload, 'base64').toString(); + return JSON.parse(payload); + } + } catch (e) { + pxConfig.logger.debug(`Failed to parse JWT token ${token}: ${e.message} `); + } + + return null; +} + +function getJWTData(pxConfig, payload) { + let additionalFields = null; + + try { + const userFieldName = pxConfig.JWT_COOKIE_USER_ID_FIELD_NAME || pxConfig.JWT_HEADER_USER_ID_FIELD_NAME; + const userID = payload[userFieldName]; + + const additionalFieldsConfig = + pxConfig.JWT_COOKIE_ADDITIONAL_FIELD_NAMES.length > 0 + ? pxConfig.JWT_COOKIE_ADDITIONAL_FIELD_NAMES + : pxConfig.JWT_HEADER_ADDITIONAL_FIELD_NAMES; + + if (additionalFieldsConfig && additionalFieldsConfig.length > 0) { + additionalFields = additionalFieldsConfig.reduce((matchedFields, fieldName) => { + if (payload[fieldName]) { + matchedFields[fieldName] = payload[fieldName]; + } + return matchedFields; + }, {}); + } + + return { userID, additionalFields }; + } catch (e) { + pxConfig.logger.debug(`Failed to extract JWT token ${payload}: ${e.message} `); + } + + return null; +} + +function extractJWTData(pxConfig, token) { + const payload = getJWTPayload(pxConfig, token); + + if (!payload) { + return null; + } + + return getJWTData(pxConfig, payload); +} + +module.exports = { + extractJWTData, +}; diff --git a/lib/pxutil.js b/lib/pxutil.js index 159a104..7ae435b 100644 --- a/lib/pxutil.js +++ b/lib/pxutil.js @@ -249,7 +249,8 @@ function generateHMAC(cookieSecret, payload) { function isReqInMonitorMode(pxConfig, pxCtx) { return ( - (pxConfig.MODULE_MODE === ModuleMode.MONITOR && !pxCtx.shouldBypassMonitor && !pxCtx.enforcedRoute) || (pxCtx.monitoredRoute && !pxCtx.shouldBypassMonitor) + (pxConfig.MODULE_MODE === ModuleMode.MONITOR && !pxCtx.shouldBypassMonitor && !pxCtx.enforcedRoute) || + (pxCtx.monitoredRoute && !pxCtx.shouldBypassMonitor) ); } @@ -277,7 +278,7 @@ function isGraphql(req, config) { return false; } try { - return routes.some(r => new RegExp(r).test(req.baseUrl || '' + req.path)); + 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; @@ -311,8 +312,10 @@ 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)); + return ( + config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(graphqlData.type) || + config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(graphqlData.name) + ); } } @@ -328,8 +331,8 @@ function getGraphqlData(graphqlBodyObject) { return null; } - const selectedOperationName = graphqlBodyObject['operationName'] || - (Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]); + const selectedOperationName = + graphqlBodyObject['operationName'] || (Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]); if (!selectedOperationName || !parsedData[selectedOperationName]) { return null; @@ -337,10 +340,7 @@ function getGraphqlData(graphqlBodyObject) { const variables = extractVariables(graphqlBodyObject.variables); - return new GraphqlData(parsedData[selectedOperationName], - selectedOperationName, - variables, - ); + return new GraphqlData(parsedData[selectedOperationName], selectedOperationName, variables); } // input: object representing variables diff --git a/lib/utils/constants.js b/lib/utils/constants.js index c9fef3b..16f851d 100644 --- a/lib/utils/constants.js +++ b/lib/utils/constants.js @@ -24,9 +24,15 @@ const CI_SSO_STEP_FIELD = 'sso_step'; const CI_CREDENTIALS_COMPROMISED_FIELD = 'credentials_compromised'; 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 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' }; +const TOKEN_SEPARATOR = '.'; +const APP_USER_ID_FIELD_NAME = 'app_user_id'; +const JWT_ADDITIONAL_FIELDS_FIELD_NAME = 'jwt_additional_fields'; +const CROSS_TAB_SESSION = 'cross_tab_session'; + module.exports = { MILLISECONDS_IN_SECOND, SECONDS_IN_MINUTE, @@ -49,5 +55,9 @@ module.exports = { CI_CREDENTIALS_COMPROMISED_FIELD, GQL_OPERATIONS_FIELD, EMAIL_ADDRESS_REGEX, - HASH_ALGORITHM -}; \ No newline at end of file + HASH_ALGORITHM, + TOKEN_SEPARATOR, + APP_USER_ID_FIELD_NAME, + JWT_ADDITIONAL_FIELDS_FIELD_NAME, + CROSS_TAB_SESSION, +}; diff --git a/package-lock.json b/package-lock.json index 3af4044..a72cc69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perimeterx-node-core", - "version": "3.7.0", + "version": "3.8.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "perimeterx-node-core", - "version": "3.7.0", + "version": "3.8.0", "license": "ISC", "dependencies": { "agent-phin": "^1.0.4", diff --git a/package.json b/package.json index ca03428..16f8975 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perimeterx-node-core", - "version": "3.7.0", + "version": "3.8.0", "description": "PerimeterX NodeJS shared core for various applications to monitor and block traffic according to PerimeterX risk score", "main": "index.js", "scripts": {