diff --git a/CHANGELOG.md b/CHANGELOG.md index 8257463..1bfa920 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.9.0] - 2023-01-29 + +### Added +- Support for CORS preflight requests and CORS headers in block responses + ## [3.8.0] - 2023-01-25 ### Added diff --git a/README.md b/README.md index b13f83d..0c2d0ea 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.8.0](https://www.npmjs.com/package/perimeterx-node-core) +> Latest stable version: [v3.9.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/pxconfig.js b/lib/pxconfig.js index 7c5f085..feab4d3 100644 --- a/lib/pxconfig.js +++ b/lib/pxconfig.js @@ -92,6 +92,10 @@ 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'], + ['CORS_SUPPORT_ENABLED', 'px_cors_support_enabled'], + ['CORS_CREATE_CUSTOM_BLOCK_RESPONSE_HEADERS', 'px_cors_create_custom_block_response_headers'], + ['CORS_CUSTOM_PREFLIGHT_HANDLER', 'px_cors_custom_preflight_handler'], + ['CORS_PREFLIGHT_REQUEST_FILTER_ENABLED', 'px_cors_preflight_request_filter_enabled'], ['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'], @@ -170,7 +174,9 @@ class PxConfig { userInput === 'px_custom_request_handler' || userInput === 'px_enrich_custom_parameters' || userInput === 'px_login_successful_custom_callback' || - userInput === 'px_modify_context' + userInput === 'px_modify_context' || + userInput === 'px_cors_create_custom_block_response_headers' || + userInput === 'px_cors_custom_preflight_handler' ) { if (typeof params[userInput] === 'function') { return params[userInput]; @@ -343,6 +349,10 @@ function pxDefaultConfig() { LOGIN_SUCCESSFUL_CUSTOM_CALLBACK: null, MODIFY_CONTEXT: null, GRAPHQL_ROUTES: ['^/graphql$'], + CORS_CUSTOM_PREFLIGHT_HANDLER: null, + CORS_CREATE_CUSTOM_BLOCK_RESPONSE_HEADERS: null, + CORS_PREFLIGHT_REQUEST_FILTER_ENABLED: false, + CORS_SUPPORT_ENABLED: false, JWT_COOKIE_NAME: '', JWT_COOKIE_USER_ID_FIELD_NAME: '', JWT_COOKIE_ADDITIONAL_FIELD_NAMES: [], @@ -411,6 +421,10 @@ const allowedConfigKeys = [ 'px_login_successful_custom_callback', 'px_modify_context', 'px_graphql_routes', + 'px_cors_support_enabled', + 'px_cors_preflight_request_filter_enabled', + 'px_cors_create_custom_block_response_headers', + 'px_cors_custom_preflight_handler', 'px_jwt_cookie_name', 'px_jwt_cookie_user_id_field_name', 'px_jwt_cookie_additional_field_names', diff --git a/lib/pxcors.js b/lib/pxcors.js new file mode 100644 index 0000000..5383403 --- /dev/null +++ b/lib/pxcors.js @@ -0,0 +1,55 @@ +const { ORIGIN_HEADER, ACCESS_CONTROL_REQUEST_METHOD_HEADER } = require('./utils/constants'); + +function isPreflightRequest(request) { + return request.method.toUpperCase() === 'OPTIONS' && request.get(ORIGIN_HEADER) && request.get(ACCESS_CONTROL_REQUEST_METHOD_HEADER); +} + +function runPreflightCustomHandler(pxConfig, request) { + const corsCustomPreflightFunction = pxConfig.CORS_CUSTOM_PREFLIGHT_HANDLER; + + if (corsCustomPreflightFunction) { + try { + return corsCustomPreflightFunction(request); + } catch (e) { + pxConfig.logger.debug(`Error while executing custom preflight handler: ${e}`); + } + } + + return null; +} + +function isCorsRequest(request) { + return request.get(ORIGIN_HEADER); +} + +function getCorsBlockHeaders(request, pxConfig, pxCtx) { + let corsHeaders = getDefaultCorsHeaders(request); + const createCustomCorsHeaders = pxConfig.CORS_CREATE_CUSTOM_BLOCK_RESPONSE_HEADERS; + + if (createCustomCorsHeaders) { + try { + corsHeaders = createCustomCorsHeaders(pxCtx, request); + } catch (e) { + pxConfig.logger.debug(`Caught error in px_cors_create_custom_block_response_headers custom function: ${e}`); + } + } + + return corsHeaders; +} + +function getDefaultCorsHeaders(request) { + const originHeader = request.get(ORIGIN_HEADER); + + if (!originHeader) { + return {}; + } + + return { 'Access-Control-Allow-Origin': originHeader, 'Access-Control-Allow-Credentials': 'true' }; +} + +module.exports = { + isPreflightRequest, + runPreflightCustomHandler, + isCorsRequest, + getCorsBlockHeaders, +}; \ No newline at end of file diff --git a/lib/pxenforcer.js b/lib/pxenforcer.js index 93461c4..5e98a95 100644 --- a/lib/pxenforcer.js +++ b/lib/pxenforcer.js @@ -23,7 +23,13 @@ const PxDataEnrichment = require('./pxdataenrichment'); const telemetryHandler = require('./telemetry_handler.js'); const LoginCredentialsExtractor = require('./extract_field/LoginCredentialsExtractor'); const { LoginSuccessfulParserFactory } = require('./extract_field/login_successful/LoginSuccessfulParserFactory'); -const { CI_RAW_USERNAME_FIELD, CI_VERSION_FIELD, CI_SSO_STEP_FIELD, CI_CREDENTIALS_COMPROMISED_FIELD } = require('./utils/constants'); +const { + CI_RAW_USERNAME_FIELD, + CI_VERSION_FIELD, + CI_SSO_STEP_FIELD, + CI_CREDENTIALS_COMPROMISED_FIELD, +} = require('./utils/constants'); +const pxCors = require('./pxcors'); class PxEnforcer { constructor(params, client) { @@ -82,6 +88,18 @@ class PxEnforcer { return cb(); } + if (this._config.CORS_SUPPORT_ENABLED && pxCors.isPreflightRequest(req)) { + const response = pxCors.runPreflightCustomHandler(this._config, req); + if (response) { + return cb(null, response); + } + + if (this._config.CORS_PREFLIGHT_REQUEST_FILTER_ENABLED) { + this.logger.debug('Skipping verification due to preflight request'); + return cb(); + } + } + if (userAgent && this._config.FILTER_BY_USERAGENT && this._config.FILTER_BY_USERAGENT.length > 0) { for (const ua of this._config.FILTER_BY_USERAGENT) { if (pxUtil.isStringMatchWith(userAgent, ua)) { @@ -327,19 +345,25 @@ class PxEnforcer { }`, ); const config = this._config; - this.generateResponse(ctx, isJsonResponse, function (responseObject) { + + this.generateResponse(ctx, isJsonResponse, function(responseObject) { const response = { status: '403', statusDescription: 'Forbidden', }; + if (pxCors.isCorsRequest(req) && config.CORS_SUPPORT_ENABLED) { + response.headers = pxCors.getCorsBlockHeaders(req, config, ctx); + } + if (ctx.blockAction === 'r') { response.status = '429'; response.statusDescription = 'Too Many Requests'; } if (isJsonResponse) { - response.header = { key: 'Content-Type', value: 'application/json' }; + pxUtil.appendContentType(response, 'application/json'); + response.body = { appId: responseObject.appId, jsClientSrc: responseObject.jsClientSrc, @@ -353,12 +377,13 @@ class PxEnforcer { }; return cb(null, response); } + + pxUtil.appendContentType(response, 'text/html'); - response.header = { key: 'Content-Type', value: 'text/html' }; response.body = responseObject; if (ctx.cookieOrigin === CookieOrigin.HEADER) { - response.header = { key: 'Content-Type', value: 'application/json' }; + pxUtil.appendContentType(response, 'application/json'); response.body = { action: pxUtil.parseAction(ctx.blockAction), uuid: ctx.uuid, diff --git a/lib/pxutil.js b/lib/pxutil.js index 7ae435b..204010a 100644 --- a/lib/pxutil.js +++ b/lib/pxutil.js @@ -379,6 +379,10 @@ function tryOrNull(fn, exceptionHandler) { } } +function appendContentType(response, contentTypeValue) { + response.headers = Object.assign(response.headers || {}, { 'Content-Type': contentTypeValue }); +} + module.exports = { isSensitiveGraphqlOperation, formatHeaders, @@ -402,4 +406,5 @@ module.exports = { isEmailAddress, isGraphql, tryOrNull, + appendContentType }; diff --git a/lib/utils/constants.js b/lib/utils/constants.js index 16f851d..a71e60f 100644 --- a/lib/utils/constants.js +++ b/lib/utils/constants.js @@ -28,6 +28,8 @@ 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 ORIGIN_HEADER = 'origin'; +const ACCESS_CONTROL_REQUEST_METHOD_HEADER = 'access-control-request-method'; const TOKEN_SEPARATOR = '.'; const APP_USER_ID_FIELD_NAME = 'app_user_id'; const JWT_ADDITIONAL_FIELDS_FIELD_NAME = 'jwt_additional_fields'; @@ -56,6 +58,8 @@ module.exports = { GQL_OPERATIONS_FIELD, EMAIL_ADDRESS_REGEX, HASH_ALGORITHM, + ORIGIN_HEADER, + ACCESS_CONTROL_REQUEST_METHOD_HEADER, TOKEN_SEPARATOR, APP_USER_ID_FIELD_NAME, JWT_ADDITIONAL_FIELDS_FIELD_NAME, diff --git a/package-lock.json b/package-lock.json index a72cc69..b300bf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "perimeterx-node-core", - "version": "3.8.0", + "version": "3.9.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "perimeterx-node-core", - "version": "3.8.0", + "version": "3.9.0", "license": "ISC", "dependencies": { "agent-phin": "^1.0.4", diff --git a/package.json b/package.json index 16f8975..20f15fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "perimeterx-node-core", - "version": "3.8.0", + "version": "3.9.0", "description": "PerimeterX NodeJS shared core for various applications to monitor and block traffic according to PerimeterX risk score", "main": "index.js", "scripts": { diff --git a/test/pxcors.test.js b/test/pxcors.test.js new file mode 100644 index 0000000..ba1c0e1 --- /dev/null +++ b/test/pxcors.test.js @@ -0,0 +1,206 @@ +'use strict'; + +const should = require('should'); +const sinon = require('sinon'); + +const pxhttpc = require('../lib/pxhttpc'); +const proxyquire = require('proxyquire'); +const rewire = require('rewire'); +const { ModuleMode } = require('../lib/enums/ModuleMode'); +const PxEnforcer = require('../lib/pxenforcer'); +const PxClient = rewire('../lib/pxclient'); + +describe('PX Cors - pxCors.js', () => { + let params, enforcer, req, stub, pxClient, pxLoggerSpy, logger, reqStub; + + beforeEach(() => { + params = { + px_app_id: 'PX_APP_ID', + px_cookie_secret: 'kabum', + px_auth_token: 'PX_AUTH_TOKEN', + px_send_async_activities_enabled: true, + px_blocking_score: 60, + px_logger_severity: true, + px_ip_headers: ['x-px-true-ip'], + px_max_activity_batch_size: 1, + px_module_enabled: true, + px_module_mode: ModuleMode.ACTIVE_BLOCKING, + px_cors_support_enabled: true, + }; + + req = {}; + req.headers = { 'origin': 'test' }; + req.cookies = {}; + req.method = 'OPTIONS'; + req.originalUrl = '/'; + req.path = req.originalUrl.substring(req.originalUrl.lastIndexOf('/')); + req.protocol = 'http'; + req.ip = '1.2.3.4'; + req.hostname = 'example.com'; + req.get = (key) => { + return req.headers[key] || ''; + }; + + pxLoggerSpy = { + debug: sinon.spy(), + error: sinon.spy(), + init: () => {}, + '@global': true, + }; + + logger = function () { + return pxLoggerSpy; + }; + + pxClient = new PxClient(); + }); + + afterEach(() => { + stub.restore(); + }); + + it('Pass preflight request due to px_cors_preflight_request_filter_enabled', (done) => { + stub = sinon.stub(pxhttpc, 'callServer').callsFake((data, headers, uri, callType, config, callback) => { + return callback ? callback(null, data) : ''; + }); + + const curParams = Object.assign( + { + px_cors_preflight_request_filter_enabled: true + }, + params + ); + + req.headers = Object.assign(req.headers, { 'access-control-request-method': 'get' }); + + const pxenforcer = proxyquire('../lib/pxenforcer', { './pxlogger': logger }); + enforcer = new pxenforcer(curParams, pxClient); + enforcer.enforce(req, null, (response) => { + pxLoggerSpy.debug.calledWith('Skipping verification due to preflight request').should.equal(true); + (response === undefined).should.equal(true); + done(); + }); + }); + + it('handle preflight request with custom handler', (done) => { + stub = sinon.stub(pxhttpc, 'callServer').callsFake((data, headers, uri, callType, config, callback) => { + return callback ? callback(null, data) : ''; + }); + + params.px_cors_custom_preflight_handler = function (req) { + const response = { + status: '404', + statusDescription: 'Test', + }; + + response.headers = { + 'Access-Control-Allow-Origin': req.headers['origin'], + 'Access-Control-Allow-Methods': req.method, + 'Access-Control-Allow-Headers': req.headers['access-control-request-headers'], + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Max-Age': '86400', + }; + + return response; + }; + + const curParams = Object.assign( + { + px_cors_preflight_request_filter_enabled: true + }, + params + ); + + req.headers = Object.assign(req.headers, { 'access-control-request-method': 'get' }); + + enforcer = new PxEnforcer(curParams, pxClient); + enforcer.enforce(req, null, (response) => { + (response !== undefined).should.equal(true); + done(); + }); + }); + + it('add default proper cors headers to block response', (done) => { + stub = sinon.stub(pxhttpc, 'callServer').callsFake((data, headers, uri, callType, config, callback) => { + data.score = 100; + data.action = 'c'; + return callback ? callback(null, data) : ''; + }); + + reqStub = sinon.stub(req, 'post').callsFake((data, callback) => { + callback(null, { body: 'hello buddy' }); + }); + + req.method = 'POST'; + + enforcer = new PxEnforcer(params, pxClient); + enforcer.enforce(req, null, (error, response) => { + should.exist(response); + should.equal(response.headers['Content-Type'], 'text/html'); + should.equal(response.headers['Access-Control-Allow-Credentials'], 'true'); + should.equal(response.headers['Access-Control-Allow-Origin'], 'test'); + reqStub.restore(); + done(); + }); + }); + + it('add custom cors headers to block response', (done) => { + stub = sinon.stub(pxhttpc, 'callServer').callsFake((data, headers, uri, callType, config, callback) => { + data.score = 100; + data.action = 'c'; + return callback ? callback(null, data) : ''; + }); + + params.px_cors_create_custom_block_response_headers = function (req) { + return { + 'Access-Control-Allow-Origin': 'test_custom', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Allow-Credentials': 'test_custom' + }; + }; + + reqStub = sinon.stub(req, 'post').callsFake((data, callback) => { + callback(null, { body: 'hello buddy' }); + }); + + req.method = 'POST'; + + enforcer = new PxEnforcer(params, pxClient); + enforcer.enforce(req, null, (error, response) => { + should.exist(response); + should.equal(response.headers['Content-Type'], 'text/html'); + should.equal(response.headers['Access-Control-Allow-Headers'], 'Content-Type, Authorization'); + should.equal(response.headers['Access-Control-Allow-Methods'], 'GET, POST, OPTIONS'); + should.equal(response.headers['Access-Control-Allow-Credentials'], 'test_custom'); + should.equal(response.headers['Access-Control-Allow-Origin'], 'test_custom'); + reqStub.restore(); + done(); + }); + }); + + it('do not add cors block response if request is not cors request', (done) => { + stub = sinon.stub(pxhttpc, 'callServer').callsFake((data, headers, uri, callType, config, callback) => { + data.score = 100; + data.action = 'c'; + return callback ? callback(null, data) : ''; + }); + + reqStub = sinon.stub(req, 'post').callsFake((data, callback) => { + callback(null, { body: 'hello buddy' }); + }); + + req.headers = {}; + req.method = 'POST'; + + enforcer = new PxEnforcer(params, pxClient); + enforcer.enforce(req, null, (error, response) => { + should.exist(response); + should.equal(response.headers['Content-Type'], 'text/html'); + (response.headers['Access-Control-Allow-Credentials'] === undefined).should.equal(true); + (response.headers['Access-Control-Allow-Origin'] === undefined).should.equal(true); + reqStub.restore(); + done(); + }); + }); +}); \ No newline at end of file diff --git a/test/pxenforcer.test.js b/test/pxenforcer.test.js index bb5e103..ee308cf 100644 --- a/test/pxenforcer.test.js +++ b/test/pxenforcer.test.js @@ -351,7 +351,7 @@ describe('PX Enforcer - pxenforcer.js', () => { enforcer = new PxEnforcer(curParams, pxClient); enforcer.enforce(req, null, (error, response) => { should.exist(response); - should.equal(response.header.value, 'text/html'); + should.equal(response.headers['Content-Type'], 'text/html'); reqStub.restore(); done(); }); @@ -377,7 +377,7 @@ describe('PX Enforcer - pxenforcer.js', () => { enforcer = new PxEnforcer(curParams, pxClient); enforcer.enforce(req, null, (error, response) => { should.exist(response); - should.equal(response.header.value, 'application/json'); + should.equal(response.headers['Content-Type'], 'application/json'); reqStub.restore(); done(); }); diff --git a/test/pxlogger.test.js b/test/pxlogger.test.js index 8fd4702..2cd995f 100644 --- a/test/pxlogger.test.js +++ b/test/pxlogger.test.js @@ -1,81 +1,81 @@ -"use strict"; +'use strict'; -const should = require("should"); -const sinon = require("sinon"); -const { LoggerSeverity } = require("../lib/enums/LoggerSeverity"); +const should = require('should'); +const sinon = require('sinon'); +const { LoggerSeverity } = require('../lib/enums/LoggerSeverity'); -const PxLogger = require("../lib/pxlogger"); +const PxLogger = require('../lib/pxlogger'); -describe("PX Logger - pxlogger.js", () => { - let params, logger, consoleInfoStub, consoleErrorStub; +describe('PX Logger - pxlogger.js', () => { + let params, logger, consoleInfoStub, consoleErrorStub; - beforeEach(() => { - params = {}; - logger = new PxLogger(params); - consoleInfoStub = sinon.stub(console, "info"); - consoleErrorStub = sinon.stub(console, "error"); - }); + beforeEach(() => { + params = {}; + logger = new PxLogger(params); + consoleInfoStub = sinon.stub(console, 'info'); + consoleErrorStub = sinon.stub(console, 'error'); + }); - afterEach(() => { - consoleInfoStub.restore(); - consoleErrorStub.restore(); - }); + afterEach(() => { + consoleInfoStub.restore(); + consoleErrorStub.restore(); + }); - it("sets default properties", (done) => { - logger = new PxLogger(params); + it('sets default properties', (done) => { + logger = new PxLogger(params); - logger.debugMode.should.equal(false); - logger.appId.should.equal("PX_APP_ID"); - logger.internalLogger.should.be.exactly(console); - done(); - }); + logger.debugMode.should.equal(false); + logger.appId.should.equal('PX_APP_ID'); + logger.internalLogger.should.be.exactly(console); + done(); + }); - it("uses console to log when no custom logger is set", (done) => { - params.px_logger_severity = LoggerSeverity.DEBUG; - logger = new PxLogger(params); + it('uses console to log when no custom logger is set', (done) => { + params.px_logger_severity = LoggerSeverity.DEBUG; + logger = new PxLogger(params); - logger.internalLogger.should.be.exactly(console); + logger.internalLogger.should.be.exactly(console); - logger.error("there was an error"); - console.error.calledOnce.should.equal(true); + logger.error('there was an error'); + console.error.calledOnce.should.equal(true); - logger.debug("debug message"); - console.info.calledOnce.should.equal(true); - done(); - }); + logger.debug('debug message'); + console.info.calledOnce.should.equal(true); + done(); + }); - it("does not call console.info when debugMode is false", (done) => { - params.px_logger_severity = false; - logger = new PxLogger(params); + it('does not call console.info when debugMode is false', (done) => { + params.px_logger_severity = false; + logger = new PxLogger(params); - logger.debugMode.should.equal(false); + logger.debugMode.should.equal(false); - logger.error("there was an error"); - console.error.calledOnce.should.equal(true); + logger.error('there was an error'); + console.error.calledOnce.should.equal(true); - logger.debug("debug message"); - console.info.calledOnce.should.equal(false); + logger.debug('debug message'); + console.info.calledOnce.should.equal(false); - done(); - }); + done(); + }); - it("uses custom logger when it is set", (done) => { - params.px_logger_severity = LoggerSeverity.DEBUG; - params.customLogger = { - info: sinon.spy(), - error: sinon.spy(), - }; - logger = new PxLogger(params); + it('uses custom logger when it is set', (done) => { + params.px_logger_severity = LoggerSeverity.DEBUG; + params.customLogger = { + info: sinon.spy(), + error: sinon.spy(), + }; + logger = new PxLogger(params); - logger.internalLogger.should.be.exactly(params.customLogger); + logger.internalLogger.should.be.exactly(params.customLogger); - logger.error("there was an error"); - console.error.called.should.equal(false); - params.customLogger.error.calledOnce.should.equal(true); + logger.error('there was an error'); + console.error.called.should.equal(false); + params.customLogger.error.calledOnce.should.equal(true); - logger.debug("debug message"); - console.info.called.should.equal(false); - params.customLogger.info.calledOnce.should.equal(true); - done(); - }); + logger.debug('debug message'); + console.info.called.should.equal(false); + params.customLogger.info.calledOnce.should.equal(true); + done(); + }); });