diff --git a/.gitignore b/.gitignore index 7d0865e..5429d7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.iml *.pyc db.sqlite3 manage.py diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..19f6c38 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,549 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + invalid-unicode-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + locally-enabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=raw_cookie,get_hmac,get_score,is_cookie_format_valid + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,io,builtins + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..15ca9ae --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - "2.7" +# command to install dependencies +install: + - pip install -r requirements.txt +# command to run tests +script: + - python -m unittest discover -s ./test -p 'test*' + - pylint -E perimeterx/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 18dd47f..b82e750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [v2.0.0](https://github.com/PerimeterX/perimeterx-python-wsgi/compare/v1.0.17...HEAD) (2018-12-03) +- Added Major Enforcer functionalities: Mobile SDK, FirstParty, CaptchaV2, Block handling +- Added unit tests + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) @@ -11,3 +15,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - New block/captcha templates - Delete captcha cookie after evaluation - Sending original cookie value when decryption fails + diff --git a/LICENSE b/LICENSE index 6e411f5..5be1142 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright © 2016 PerimeterX, Inc. +Copyright © 2018 PerimeterX, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 755327a..51a0f85 100644 --- a/README.md +++ b/README.md @@ -1,329 +1,175 @@ -![image](http://media.marketwire.com/attachments/201604/34215_PerimeterX_logo.jpg) - -[PerimeterX](http://www.perimeterx.com) Python WSGI Middleware +[![Build Status](https://travis-ci.org/PerimeterX/perimeterx-python-wsgi.svg?branch=master)](https://travis-ci.org/PerimeterX/perimeterx-python-wsgi) +![image](https://s.perimeterx.net/logo.png) +[PerimeterX](http://www.perimeterx.com) Python Middleware ============================================================= -> The PerimeterX Python Middleware is supported by all [WSGI based frameworks](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface#WSGI-compatible_applications_and_frameworks). - +> Latest stable version: [v2.0.0](https://pypi.org/project/perimeterx-python-wsgi/) Table of Contents ----------------- - -- [Usage](#usage) - * [Dependencies](#dependencies) - * [Installation](#installation) - * [Basic Usage Example](#basic-usage) -- [Configuration](#configuration) - * [Blocking Score](#blocking-score) - * [Customizing Block page](#custom-block-page) - * [Custom Block Action](#custom-block) - * [Enable/Disable Server Calls](#server-calls) - * [Enable/Disable Captcha](#captcha-support) - * [Extracting Real IP Address](#real-ip) - * [Filter Sensitive Headers](#sensitive-headers) - * [API Timeouts](#api-timeout) - * [Send Page Activities](#send-page-activities) - * [Debug Mode](#debug-mode) -- [Contributing](#contributing) - * [Tests](#tests) - - - - Dependencies ----------------------------------------- - -- [Python v2.7](https://www.python.org/download/releases/2.7/) -- [pycrypto v2.6](https://pypi.python.org/pypi/pycrypto) - - Note: pycrypto is a python core module, this need to be manually added to dependencies when using GAE - - - Installation ----------------------------------------- - -Installation can be done using Composer. - -```sh -$ pip install perimeterx-python-wsgi -``` - -### Basic Usage Example -##### Django: - +- [Installation](#installation) +- [Required Configuration](#required_config) +- [Upgrading](#upgrading) +- [Advanced Blocking Response](#advanced_blocking_response) +- [Optional Configuration](#configuration) + * [Module Enabled](#module_enabled) + * [Module Mode](#module_mode) + * [Blocking Score](#blocking_score) + * [Send Page Activities](#send_page_activities) + * [Debug Mode](#debug_mode) + * [Sensitive Routes](#sensitive_routes) + * [Whitelist Routes](#whitelist_routes) + * [Sensitive Headers](#sensitive_headers) + * [IP Headers](#ip_headers) + * [First-Party Enabled](#first_party_enabled) + * [Custom Request Handler](#custom_request_handler) + * [Additional Activity Handler](#additional_activity_handler) +## Installation +PerimeterX Python middleware is installed via PIP: +`$ pip install perimeterx-python-wsgi` +## Upgrading +Contact [PerimeterX Support](mailto: support@perimeterx.com) for details. +## Required Configurations +To use PerimeterX middleware on a specific route follow this example: ```python -from perimeterx.middleware import PerimeterX - px_config = { - 'app_id': 'APP_ID', - 'cookie_key': 'COOKIE_KEY', - 'auth_token': 'AUTH_TOKEN', - 'blocking_score': 70 + 'app_id': 'APP_ID', + 'cookie_key': 'COOKIE_KEY', + 'auth_token': 'AUTH_TOKEN', } - application = get_wsgi_application() application = PerimeterX(application, px_config) ``` -##### Google App Engine: -app.yaml: - -```yaml -libraries: -- name: pycrypto - version: 2.6 -``` - +- The PerimeterX **Application ID** / **AppId** and PerimeterX **Token** / **Auth Token** can be found in the Portal, in [Applications](https://console.perimeterx.com/#/app/applicationsmgmt). +- PerimeterX **Risk Cookie** / **Cookie Key** can be found in the portal, in [Policies](https://console.perimeterx.com/#/app/policiesmgmt). +The Policy from where the **Risk Cookie** / **Cookie Key** is taken must correspond with the Application from where the **Application ID** / **AppId** and PerimeterX **Token** / **Auth Token**. +For details on how to create a custom Captcha page, refer to the [documentation](https://console.perimeterx.com/docs/server_integration_new.html#custom-captcha-section) +## Optional Configuration +In addition to the basic installation configuration [above](#required_config), the following configurations options are available: +#### Module Enabled +A boolean flag to enable/disable the PerimeterX Enforcer. +**Default:** true ```python -import webapp2 -from perimeterx.middleware import PerimeterX - -app = webapp2.WSGIApplication([ - ('/', MainPage), -], debug=True) - -px_config = { - 'app_id': 'APP_ID', - 'cookie_key': 'COOKIE_KEY', - 'auth_token': 'AUTH_TOKEN', - 'blocking_score': 70 -} - -app = PerimeterX(app, px_config) -``` - -### Configuration Options - -#### Configuring Required Parameters - -Configuration options are set in the `px_config` variable. - -#### Required parameters: - -- app_id -- cookie_key -- auth_token - -#### Changing the Minimum Score for Blocking Requests - -**default:** 70 - -```python -px_config = { - .. - 'blocking_score': 75 - .. +config = { + ... + module_enabled: False + ... } ``` - -### Customizing Block Page -#### Customizing logo -Adding a custom logo to the blocking page is by providing the pxConfig a key ```custom_logo``` , the logo will be displayed at the top div of the the block page The logo's ```max-heigh``` property would be 150px and width would be set to ``auto`` - -The key customLogo expects a valid URL address such as https://s.perimeterx.net/logo.png - -Example below: +#### Module Mode +Sets the working mode of the Enforcer. +Possible values: +* `active_blocking` - Blocking Mode +* `monitor` - Monitoring Mode +**Default:** `monitor` - Monitor Mode ```python -px_config = { - .. - 'custom_logo': 'https://s.perimeterx.net/logo.png' - .. +config = { + ... + module_mode: 'active_blocking' + ... } ``` - -#### Custom JS/CSS - -The block page can be modified with a custom CSS by adding to the pxConfig the key ```css_ref``` and providing a valid URL to the css In addition there is also the option to add a custom JS file by adding ```js_ref``` key to the pxConfig and providing the JS file that will be loaded with the block page, this key also expects a valid URL - -On both cases if the URL is not a valid format an exception will be thrown -Example below: - -Example below: +#### Blocking Score +Sets the minimum blocking score of a request. +Possible values: +* Any integer between 0 and 100. +**Default:** 100 ```python -px_config = { - .. - 'js_ref': 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js' - 'css_ref': 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css' - .. +config = { + ... + blocking_score: 100 + ... } ``` - -#### Custom Blocking Actions -Defining a custom block handler is done by setting the value of `custom_block_handler` to a user-defined function, on the `px_config` variable. - -The custom block handler should contain the action to take when a visitng user is given a high score. Common customizations are to present a reCAPTHA or a custom branded Block Page. - -**default:** return HTTP status code 403 and serve the PerimeterX block page. - -```python -def custom_block_handler(ctx, start_response): - start_response('403 Forbidden', [('Content-Type', 'text/html')]) - return ['You have been blocked'] - - -px_config = { - .. - 'custom_block_handler': custom_block_handler, - .. -} - -application = get_wsgi_application() -application = PerimeterX(application, px_config) -``` - -###### Examples - -**Serve a Custom HTML Page** - +#### Send Page Activities +Enable/disable sending activities and metrics to PerimeterX with each request.
+Enabling this feature allows data to populate the PerimeterX Portal with valuable information, such as the number of requests blocked and additional API usage statistics. +**Default:** true ```python -def custom_block_handler(ctx, start_response): - block_score = ctx.get('risk_score') - block_uuid = ctx.get('uuid') - full_url = ctx.get('full_url') - - html = '
Access to ' + full_url + ' has been blocked.
' \ - '
Block reference - ' + uuid + '
' \ - '
Block score - ' + block_score + '
' - - start_response('403 Forbidden', [('Content-Type', 'text/html')]) - return [html] -}; - -px_config = { - .. - 'custom_block_handler': custom_block_handler, - .. +config = { + ... + send_page_activities: True + ... } - -application = get_wsgi_application() -application = PerimeterX(application, px_config) ``` - -#### Module Mode - -**default:** `active_monitoring` - -**Applicable Values:** - `['active_monitoring', 'active_blocking', 'inactive']` - +#### Debug Mode +Enable/disable the debug log messages. +**Default:** False ```python -px_config = { - .. - 'module_mode': 'active_blocking' - .. +config = { + ... + debug_mode: True + ... } ``` - -#### Enable/Disable Server Calls - -By disabling server calls, the module will only evaluate users by their cookie. Users without a cookie will not generate a request to the PerimeterX servers. - -**default:** `True` - +#### Sensitive Routes +An array of route prefixes that trigger a server call to PerimeterX servers every time the page is viewed, regardless of viewing history. +**Default:** Empty ```python -px_config = { - .. - 'server_calls_enabled': False - .. +const config = { + ... + sensitive_routes: ['/login', '/user/checkout'] + ... } ``` - -#### Enable/Disable CAPTCHA on the block page - -By enabling CAPTCHA support, a CAPTCHA will be served as part of the block page, giving real users the ability to answer, get their score cleaned up and navigate to the requested page. - -**default: True** - +#### Whitelist Routes +An array of route prefixes which will bypass enforcement (will never get scored). +**Default:** Empty ```python -px_config = { - .. - 'captcha_enabled': True - .. +config = { + ... + whitelist_routes: ['/about-us', '/careers'] + ... } ``` - -#### Extracting the Real User IP Address - -> Note: IP extraction, according to your network setup, is important. It is common to have a load balancer/proxy on top of your applications, in this case the PerimeterX module will send an internal IP as the user's. In order to perform processing and detection for server-to-server calls, PerimeterX's module requires the real user's IP. - -The user's IP can be returned to the PerimeterX module, using a custom user defined function on the `px_config` variable. - -**default value:** `environ.get('REMOTE_ADDR')` - +#### Sensitive Headers +An array of headers that are not sent to PerimeterX servers on API calls. +**Default:** ['cookie', 'cookies'] ```python -def ip_handler(environ): - for key in environ.keys(): - if key == 'HTTP_X_FORWARDED_FOR': - xff = environ[key].split(' ')[1] - return xff - return '1.2.3.4' - -px_config = { - .. - 'ip_handler': ip_handler, - .. +config = { + ... + sensitive_headers: ['cookie', 'cookies', 'x-sensitive-header'] + ... } - - -application = get_wsgi_application() -application = PerimeterX(application, px_config) ``` - -#### Filter sensitive headers - -A user can define a list of sensitive headers that will be excluded from any message sent to PerimeterX's servers (lowere case header names). Filtering the 'cookie' header is set by default (for privacy) and will be overridden if a user specifies otherwise in the configuration. - -**default value:** `['cookie', 'cookies']` - +#### IP Headers +An array of trusted headers that specify an IP to be extracted. +**Default:** Empty ```python -px_config = { - .. - 'sensitive_headers': ['cookie', 'cookies', 'secret-header'] - .. +config = { + ... + ip_headers: ['x-user-real-ip'] + ... } ``` - -#### API Timeouts - -Controls the timeouts for PerimeterX requests. The API is called when the risk cookie does not exist, is expired or is invalid. - -API Timeout in seconds (float) to wait for the PerimeterX servers' API response. - - -**default:** 1 - +#### First-Party Enabled +Enable/disable First-Party mode. +**Default:** True ```python -px_config = { - .. - 'api_timeout': 2 - .. +const pxConfig = { + ... + first_party_enabled: False + ... } ``` - - -#### Send Page Activities - -A boolean flag to determine whether or not to send activities and metrics to -PerimeterX, on each page request. Disabling this feature will prevent PerimeterX from receiving data -populating the PerimeterX portal, containing valuable information such as -the amount of requests blocked and other API usage statistics. - -**default:** True - +#### Custom Request Handler +A Python function that adds a custom response handler to the request.
+You must declare the function before using it in the config.
+The Custom Request Handler is triggered after PerimeterX's verification. +The custom function should handle the response (most likely it will create a new response) +**Default:** Empty ```python -px_config = { - .. - 'send_page_activities': False - .. +config = { + ... + custom_request_handler: custom_request_handler_function, + ... } ``` - -#### Debug Mode - -Enables debug logging. - -**default:** false - +#### Additional Activity Handler +A Python function that allows interaction with the request data collected by PerimeterX before the data is returned to the PerimeterX servers. Does not alter the response. +**Default:** Empty ```python -px_config = { - .. - 'debug_mode': True - .. +config = { + ... + additional_activity_handler: additional_activity_handler_function, + ... } -``` - Contributing ----------------------------------------- +``` \ No newline at end of file diff --git a/examples/django.py b/examples/django.py deleted file mode 100644 index 8745db1..0000000 --- a/examples/django.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -PerimeterX example app implemented in a django based environment -""" - -import os -from perimeterx.middleware import PerimeterX -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "appname.settings") - - -def ip_handler(environ): - for key in environ.keys(): - if key == 'HTTP_X_FORWARDED_FOR': - xff = environ[key].split(' ')[1] - return xff - return '1.2.3.4' - - -def custom_block_handler(ctx, start_response): - uuid = ctx.get('uuid') - block_score = ctx.get('risk_score') - vid = ctx.get('vid') - app_id = 'PX_APP_ID' - captcha = True - html_head = '' - captcha_code = '' - body_start = ' You have been blocked!' - body_captcha = '

' - px_snippet = '' - body_end = '
Block Reference: #' + uuid + '
' - - print 'user id: ' + vid + ' blocked with score: ' + str(block_score) + ' ref: #' + uuid - if captcha: - custom_block_page = html_head + captcha_code + body_start + body_captcha + px_snippet + body_end - else: - custom_block_page = html_head + body_start + px_snippet + body_end - - start_response("403 Forbidden", [('Content-Type', 'text/html')]) - return [str(custom_block_page)] - - -px_config = { - 'app_id': 'PX_APP_ID', - 'cookie_key': 'PX_COOKIE_KEY', - 'auth_token': 'PX_AUTH_TOKEN', - 'blocking_score': 70, - 'debug_mode': True, - 'ip_handler': ip_handler, - 'captcha_enabled': True, - 'custom_block_handler': custom_block_handler, - 'module_mode': 'active_blocking' -} - -application = get_wsgi_application() -application = PerimeterX(application, px_config) diff --git a/examples/gae.py b/examples/gae.py deleted file mode 100644 index 9904271..0000000 --- a/examples/gae.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -PerimeterX example app implemented in a google app engine based environment -""" - -import webapp2 -from perimeterx.middleware import PerimeterX - - -class MainPage(webapp2.RequestHandler): - def get(self): - index_html = open('index.html').read() - self.response.out.write(index_html) - - -def ip_handler(environ): - for key in environ.keys(): - if key == 'HTTP_X_FORWARDED_FOR': - xff = environ[key].split(' ')[1] - return xff - return '1.2.3.4' - - -def custom_block_handler(ctx, start_response): - uuid = ctx.get('uuid') - block_score = ctx.get('risk_score') - vid = ctx.get('vid') - app_id = 'PX_APP_ID' - captcha = True - html_head = '' - captcha_code = '' - - body_start = ' You have been blocked!' - body_captcha = '

' - px_snippet = '' - body_end = '
Block Reference: #' + uuid + '
' - - print 'user id: ' + vid + ' blocked with score: ' + str(block_score) + ' ref: #' + uuid - if captcha: - custom_block_page = html_head + captcha_code + body_start + body_captcha + px_snippet + body_end - else: - custom_block_page = html_head + body_start + px_snippet + body_end - - start_response("403 Forbidden", [('Content-Type', 'text/html')]) - return [str(custom_block_page)] - - -px_config = { - 'app_id': 'PX_APP_ID', - 'cookie_key': 'PX_COOKIE_KEY', - 'auth_token': 'PX_AUTH_TOKEN', - 'blocking_score': 70, - 'debug_mode': True, - 'ip_handler': ip_handler, - 'captcha_enabled': True, - 'custom_block_handler': custom_block_handler, - 'module_mode': 'active_blocking' -} - -app = webapp2.WSGIApplication([ - ('/', MainPage), -], debug=True) -app = PerimeterX(app, px_config) diff --git a/perimeterx/__init__.py b/perimeterx/__init__.py index fc7e88d..4a6cf4f 100644 --- a/perimeterx/__init__.py +++ b/perimeterx/__init__.py @@ -1,2 +1,3 @@ __author__ = 'bend' __copyright__ = 'Copyright PerimeterX, Inc.' +import perimeterx diff --git a/perimeterx/middleware.py b/perimeterx/middleware.py index 985a2e0..ee3b816 100644 --- a/perimeterx/middleware.py +++ b/perimeterx/middleware.py @@ -1,129 +1,113 @@ -from px_logger import Logger -import px_context import px_activities_client -import px_cookie -import px_httpc -import px_captcha +import px_cookie_validator +from px_context import PxContext +import px_blocker import px_api -import px_template -import Cookie - +import px_constants +import px_utils +from perimeterx.px_proxy import PXProxy +from px_config import PxConfig class PerimeterX(object): def __init__(self, app, config=None): self.app = app # merging user's defined configurations with the default one - self.config = { - 'blocking_score': 60, - 'debug_mode': False, - 'module_version': 'Python SDK v1.2.0', - 'module_mode': 'active_monitoring', - 'perimeterx_server_host': 'sapi.perimeterx.net', - 'captcha_enabled': True, - 'server_calls_enabled': True, - 'sensitive_headers': ['cookie', 'cookies'], - 'send_page_activities': True, - 'api_timeout': 1, - 'custom_logo': None, - 'css_ref': None, - 'js_ref': None - } - - self.config = dict(self.config.items() + config.items()) - self.config['logger'] = logger = Logger(self.config['debug_mode']) - if not config['app_id']: + px_config = PxConfig(config) + logger = px_config.logger + if not px_config.app_id: logger.error('PX App ID is missing') raise ValueError('PX App ID is missing') # if APP_ID is not set, use the deafult perimeterx server - else, use the appid specific sapi. - self.config['perimeterx_server_host'] = 'sapi.perimeterx.net' if self.config['app_id'] == 'PX_APP_ID' else 'sapi-' + self.config['app_id'].lower() + '.perimeterx.net' - if not config['auth_token']: + if not px_config.auth_token: logger.error('PX Auth Token is missing') raise ValueError('PX Auth Token is missing') - if not config['cookie_key']: + if not px_config.cookie_key: logger.error('PX Cookie Key is missing') raise ValueError('PX Cookie Key is missing') - - px_httpc.init(self.config) + self.reverse_proxy_prefix = px_config.app_id[2:].lower() + self._PXBlocker = px_blocker.PXBlocker() + self._config = px_config + px_activities_client.init_activities_configuration(px_config) + px_activities_client.send_enforcer_telemetry_activity(config=px_config, update_reason='initial_config') def __call__(self, environ, start_response): - def custom_start_response(status, headers, exc_info=None): - cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE')) - if cookies.get('_pxCaptcha') and cookies.get('_pxCaptcha').value: - cookie = Cookie.SimpleCookie() - cookie['_pxCaptcha'] = ''; - cookie['_pxCaptcha']['expires'] = 'Expires=Thu, 01 Jan 1970 00:00:00 GMT'; - headers.append(('Set-Cookie', cookie['_pxCaptcha'].OutputString())) - self.config['logger'].debug('Cleared Cookie'); - return start_response(status, headers, exc_info) - - return self._verify(environ, custom_start_response) + return self._verify(environ, start_response) def _verify(self, environ, start_response): - logger = self.config['logger'] - ctx = px_context.build_context(environ, self.config) - - if ctx.get('module_mode') == 'inactive' or is_static_file(ctx): - logger.debug('Filter static file request. uri: ' + ctx.get('uri')) - return self.app(environ, start_response) - - cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE')) - if self.config.get('captcha_enabled') and cookies.get('_pxCaptcha') and cookies.get('_pxCaptcha').value: - pxCaptcha = cookies.get('_pxCaptcha').value - if px_captcha.verify(ctx, self.config, pxCaptcha): - logger.debug('User passed captcha verification. user ip: ' + ctx.get('socket_ip')) + config = self.config + logger = config.logger + try: + ctx = PxContext(environ, config) + uri = ctx.uri + px_proxy = PXProxy(config) + if px_proxy.should_reverse_request(uri): + body = environ['wsgi.input'].read(int(environ.get('CONTENT_LENGTH'))) if environ.get( + 'CONTENT_LENGTH') else '' + return px_proxy.handle_reverse_request(self.config, ctx, start_response, body) + if px_utils.is_static_file(ctx): + logger.debug('Filter static file request. uri: ' + uri) + return self.app(environ, start_response) + if not self._config._module_enabled: + logger.debug('Module is disabled, request will not be verified') return self.app(environ, start_response) - # PX Cookie verification - if not px_cookie.verify(ctx, self.config) and self.config.get('server_calls_enabled', True): - # Server-to-Server verification fallback - if not px_api.verify(ctx, self.config): + if ctx.whitelist_route: + logger.debug('The requested uri is whitelisted, passing request') return self.app(environ, start_response) - return self.handle_verification(ctx, self.config, environ, start_response) + # PX Cookie verification + if not px_cookie_validator.verify(ctx, config): + # Server-to-Server verification fallback + if not px_api.verify(ctx, self.config): + return self.app(environ, start_response) + return self.handle_verification(ctx, self.config, environ, start_response) + except: + logger.error("Caught exception, passing request") + self.pass_traffic(PxContext({}, config)) + return self.app(environ, start_response) def handle_verification(self, ctx, config, environ, start_response): - score = ctx.get('risk_score', -1) - - if score < config['blocking_score']: - return self.pass_traffic(environ, start_response, ctx) - - if config.get('custom_block_handler', False): - px_activities_client.send_block_activity(ctx, config) - return config['custom_block_handler'](ctx, start_response) - elif config.get('module_mode', 'active_monitoring') == 'active_blocking': - vid = ctx.get('vid', '') - uuid = ctx.get('uuid', '') - template = 'block' - if config.get('captcha_enabled', False): - template = 'captcha' - - body = px_template.get_template(template, self.config, uuid, vid) - - px_activities_client.send_block_activity(ctx, config) - start_response("403 Forbidden", [('Content-Type', 'text/html')]) - return [str(body)] + score = ctx.score + result = None + headers = None + status = None + pass_request = True + if score < config.blocking_score: + self.pass_traffic(ctx) else: - return self.pass_traffic(environ, start_response, ctx) - - def pass_traffic(self, environ, start_response, ctx): - details = {} - if(ctx.get('decoded_cookie','')): - details = {"px_cookie": ctx['decoded_cookie']} - px_activities_client.send_to_perimeterx('page_requested', ctx, self.config, details) - return self.app(environ, start_response) - - -def is_static_file(ctx): - uri = ctx.get('uri', '') - static_extensions = ['.css', '.bmp', '.tif', '.ttf', '.docx', '.woff2', '.js', '.pict', '.tiff', '.eot', - '.xlsx', '.jpg', '.csv', '.eps', '.woff', '.xls', '.jpeg', '.doc', '.ejs', '.otf', '.pptx', - '.gif', '.pdf', '.swf', '.svg', '.ps', '.ico', '.pls', '.midi', '.svgz', '.class', '.png', - '.ppt', '.mid', 'webp', '.jar'] - - for ext in static_extensions: - if uri.endswith(ext): - return True - return False + pass_request = False + self.block_traffic(ctx) + + if config.additional_activity_handler: + config.additional_activity_handler(ctx, config) + + if config.module_mode == px_constants.MODULE_MODE_BLOCKING and result is None and not pass_request: + result, headers, status = self.px_blocker.handle_blocking(ctx=ctx, config=config) + if config.custom_request_handler: + custom_body, custom_headers, custom_status = config.custom_request_handler(ctx, self.config, environ) + if custom_body is not None: + start_response(custom_status, custom_headers) + return custom_body + + if headers is not None: + start_response(status, headers) + return result + else: + return self.app(environ, start_response) + + def pass_traffic(self, ctx): + px_activities_client.send_page_requested_activity(ctx, self.config) + + def block_traffic(self, ctx): + px_activities_client.send_block_activity(ctx, self.config) + + @property + def config(self): + return self._config + + @property + def px_blocker(self): + return self._PXBlocker diff --git a/perimeterx/px_activities_client.py b/perimeterx/px_activities_client.py index 7a783cc..95f09b4 100644 --- a/perimeterx/px_activities_client.py +++ b/perimeterx/px_activities_client.py @@ -2,45 +2,49 @@ import px_httpc import threading import traceback, sys +import px_constants +import socket +import json ACTIVITIES_BUFFER = [] CONFIG = {} +def init_activities_configuration(config): + global CONFIG + CONFIG = config + t1 = threading.Thread(target=send_activities) + t1.daemon = True + t1.start() + + def send_activities(): global ACTIVITIES_BUFFER + default_headers = { + 'Authorization': 'Bearer ' + CONFIG.auth_token, + 'Content-Type': 'application/json' + } + full_url = CONFIG.server_host + px_constants.API_ACTIVITIES while True: if len(ACTIVITIES_BUFFER) > 0: chunk = ACTIVITIES_BUFFER[:10] ACTIVITIES_BUFFER = ACTIVITIES_BUFFER[10:] - px_httpc.send('/api/v1/collector/s2s', chunk, CONFIG) + px_httpc.send(full_url=full_url, body=json.dumps(chunk), headers=default_headers, config=CONFIG, + method='POST') time.sleep(1) -t1 = threading.Thread(target=send_activities) -t1.daemon = True -t1.start() - - def send_to_perimeterx(activity_type, ctx, config, detail): - global CONFIG try: - print activity_type - if not config.get('server_calls_enabled', True): - return - - if activity_type == 'page_requested' and not config.get('send_page_activities', False): + if activity_type == 'page_requested' and not config.send_page_activities: print 'Page activities disabled in config - skipping.' return - if len(CONFIG.keys()) == 0: - CONFIG = config - _details = { - 'http_method': ctx.get('http_method', ''), - 'http_version': ctx.get('http_version', ''), - 'module_version': config.get('module_version', ''), - 'risk_mode': config.get('module_mode', '') + 'http_method': ctx.http_method, + 'http_version': ctx.http_version, + 'module_version': config.module_version, + 'risk_mode': config.module_mode, } if len(detail.keys()) > 0: @@ -48,16 +52,15 @@ def send_to_perimeterx(activity_type, ctx, config, detail): data = { 'type': activity_type, - 'headers': ctx.get('headers'), + 'headers': ctx.headers, 'timestamp': int(round(time.time() * 1000)), - 'socket_ip': ctx.get('socket_ip'), - 'px_app_id': config.get('app_id'), - 'url': ctx.get('full_url'), + 'socket_ip': ctx.ip, + 'px_app_id': config.app_id, + 'url': ctx.full_url, 'details': _details, - 'vid': ctx.get('vid', ''), - 'uuid': ctx.get('uuid', '') + 'vid': ctx.vid, + 'uuid': ctx.uuid } - print 'appending' ACTIVITIES_BUFFER.append(data) except: print traceback.format_exception(*sys.exc_info()) @@ -65,8 +68,46 @@ def send_to_perimeterx(activity_type, ctx, config, detail): def send_block_activity(ctx, config): - send_to_perimeterx('block', ctx, config, { - 'block_score': ctx.get('risk_score'), - 'client_uuid': ctx.get('uuid'), - 'block_reason': ctx.get('block_reason') + send_to_perimeterx(px_constants.BLOCK_ACTIVITY, ctx, config, { + 'block_score': ctx.score, + 'client_uuid': ctx.uuid, + 'block_reason': ctx.block_reason, + 'http_method': ctx.http_method, + 'http_version': ctx.http_version, + 'px_cookie': ctx.decoded_cookie, + 'risk_rtt': ctx.risk_rtt, + 'cookie_origin': ctx.cookie_origin, + 'block_action': ctx.block_action, + 'module_version': px_constants.MODULE_VERSION, + 'simulated_block': config.module_mode is px_constants.MODULE_MODE_MONITORING, }) + + +def send_page_requested_activity(ctx, config): + details = {} + if ctx.decoded_cookie: + details = {"px_cookie": ctx.decoded_cookie} + send_to_perimeterx(px_constants.PAGE_REQUESTED_ACTIVITY, ctx, config, details) + + +def send_enforcer_telemetry_activity(config, update_reason): + details = { + 'enforcer_configs': config.telemetry_config, + 'node_name': socket.gethostname(), + 'os_name': sys.platform, + 'update_reason': update_reason, + 'module_version': config.module_version + } + body = { + 'type': px_constants.TELEMETRY_ACTIVITY, + 'timestamp': time.time(), + 'px_app_id': config.app_id, + 'details': details + } + headers = { + 'Authorization': 'Bearer ' + config.auth_token, + 'Content-Type': 'application/json' + } + config.logger.debug('Sending telemetry activity to PerimeterX servers') + px_httpc.send(full_url=config.server_host + px_constants.API_ENFORCER_TELEMETRY, body=json.dumps(body), + headers=headers, config=config, method='POST') diff --git a/perimeterx/px_api.py b/perimeterx/px_api.py index a022523..87cadac 100644 --- a/perimeterx/px_api.py +++ b/perimeterx/px_api.py @@ -1,22 +1,64 @@ -import sys import px_httpc +import time +import px_constants +import json +import re + +custom_params = { + 'custom_param1': '', + 'custom_param2': '', + 'custom_param3': '', + 'custom_param4': '', + 'custom_param5': '', + 'custom_param6': '', + 'custom_param7': '', + 'custom_param8': '', + 'custom_param9': '', + 'custom_param10': '' +} def send_risk_request(ctx, config): body = prepare_risk_body(ctx, config) - return px_httpc.send('/api/v1/risk', body, config) + default_headers = { + 'Authorization': 'Bearer ' + config.auth_token, + 'Content-Type': 'application/json' + } + response = px_httpc.send(full_url=config.server_host + px_constants.API_RISK, body=json.dumps(body), config=config, + headers=default_headers, method='POST') + return json.loads(response.content) def verify(ctx, config): - logger = config['logger'] + logger = config.logger + logger.debug("PXVerify") try: + start = time.time() response = send_risk_request(ctx, config) + risk_rtt = time.time() - start + logger.debug('Risk call took ' + str(risk_rtt) + 'ms') + if response: - ctx['risk_score'] = response['scores']['non_human'] - ctx['uuid'] = response['uuid'] - if ctx['risk_score'] >= config['blocking_score']: - ctx['block_reason'] = 's2s_high_score' + ctx.score = response.get('score') + ctx.uuid = response.get('uuid') + ctx.block_action = response.get('action') + ctx.risk_rtt = risk_rtt + if ctx.score >= config.blocking_score: + if response.get('action') == px_constants.ACTION_CHALLENGE and response.get('action_data') is not None and response.get( + 'action_data').get('body') is not None: + logger.debug("PXVerify received javascript challenge action") + ctx.block_action_data = response.get('action_data').get('body') + ctx.block_reason = 'challenge' + elif response.get('action') is px_constants.ACTION_RATELIMIT: + logger.debug("PXVerify received javascript ratelimit action") + ctx.block_reason = 'exceeded_rate_limit' + else: + logger.debug("PXVerify block score threshold reached, will initiate blocking") + ctx.block_reason = 's2s_high_score' + else: + ctx.pass_reason = 's2s' + logger.debug("PxAPI[verify] S2S completed") return True else: return False @@ -26,33 +68,64 @@ def verify(ctx, config): def prepare_risk_body(ctx, config): - logger = config['logger'] + logger = config.logger + logger.debug("PxAPI[send_risk_request]") body = { 'request': { - 'ip': ctx.get('socket_ip'), - 'headers': format_headers(ctx.get('headers')), - 'uri': ctx.get('uri'), - 'url': ctx.get('full_url', '') + 'ip': ctx.ip, + 'headers': format_headers(ctx.headers), + 'uri': ctx.uri, + 'url': ctx.full_url, + 'firstParty': 'true' if config.first_party else 'false' }, - 'vid': ctx.get('vid', ''), - 'uuid': ctx.get('uuid', ''), 'additional': { - 's2s_call_reason': ctx.get('s2s_call_reason', ''), - 'http_method': ctx.get('http_method', ''), - 'http_version': ctx.get('http_version', ''), - 'module_version': config.get('module_version', ''), - 'risk_mode': config.get('module_mode', '') + 's2s_call_reason': ctx.s2s_call_reason, + 'http_method': ctx.http_method, + 'http_version': ctx.http_version, + 'module_version': config.module_version, + 'risk_mode': config.module_mode, + 'cookie_origin': ctx.cookie_origin } } + if ctx.vid: + body['vid'] = ctx.vid + if ctx.uuid: + body['uuid'] = ctx.uuid + if ctx.cookie_hmac: + body['additional']['px_cookie_hmac'] = ctx.cookie_hmac + if ctx.cookie_names: + body['additional']['request_cookie_names'] = ctx.cookie_names + + + body = add_original_token_data(ctx, body) - if ctx['s2s_call_reason'] == 'cookie_decryption_failed': + if config.enrich_custom_parameters: + risk_custom_params = config.enrich_custom_parameters(custom_params) + for param in risk_custom_params: + if re.match('^custom_param\d$', param) and risk_custom_params[param]: + body['additional'][param] = risk_custom_params[param] + + if ctx.s2s_call_reason == 'cookie_decryption_failed': logger.debug('attaching orig_cookie to request') - body['additional']['px_cookie_orig'] = ctx.get('px_orig_cookie') + body['additional']['px_orig_cookie'] = ctx.px_orig_cookie - if ctx['s2s_call_reason'] in ['cookie_expired', 'cookie_validation_failed']: + if ctx.s2s_call_reason in ['cookie_expired', 'cookie_validation_failed']: logger.debug('attaching px_cookie to request') - body['additional']['px_cookie'] = ctx.get('decoded_cookie') + body['additional']['px_cookie'] = ctx.decoded_cookie + + logger.debug("PxAPI[send_risk_request] request body: " + str(body)) + return body + +def add_original_token_data(ctx, body): + if ctx.original_uuid: + body['additional']['original_uuid'] = ctx.original_uuid + if ctx.original_token_error: + body['additional']['original_token_error'] = ctx.original_token_error + if ctx.original_token: + body['additional']['original_token'] = ctx.original_token + if ctx.decoded_original_token: + body['additional']['decoded_original_token'] = ctx.decoded_original_token return body diff --git a/perimeterx/px_blocker.py b/perimeterx/px_blocker.py new file mode 100644 index 0000000..40388b3 --- /dev/null +++ b/perimeterx/px_blocker.py @@ -0,0 +1,106 @@ +import pystache +import px_template +import px_constants +import json +import base64 + + +class PXBlocker(object): + def __init__(self): + self.mustache_renderer = pystache.Renderer() + self.ratelimit_rendered_page = self.mustache_renderer.render( + px_template.get_template(px_constants.RATELIMIT_TEMPLATE), {}) + + def handle_blocking(self, ctx, config): + action = ctx.block_action + status = '403 Forbidden' + + is_json_response = self.is_json_response(ctx) + if is_json_response: + content_type = 'application/json' + else: + content_type = 'text/html' + headers = [('Content-Type', content_type)] + + if action is px_constants.ACTION_CHALLENGE: + blocking_props = ctx.block_action_data + blocking_response = blocking_props + elif action is px_constants.ACTION_RATELIMIT: + blocking_response = self.ratelimit_rendered_page + status = '429 Too Many Requests' + else: + blocking_props = self.prepare_properties(ctx, config) + blocking_response = self.mustache_renderer.render(px_template.get_template(px_constants.BLOCK_TEMPLATE), + blocking_props) + + if ctx.is_mobile: + page_response = json.dumps({ + 'action': parse_action(ctx.block_action), + 'uuid': ctx.uuid, + 'vid': ctx.vid, + 'appId': config.app_id, + 'page': base64.b64encode(blocking_response), + 'collectorURL': 'https://' + config.collector_host + }) + return page_response, headers, status + + if is_json_response: + blocking_response = json.dumps(blocking_props) + + blocking_response = str(blocking_response) + return blocking_response, headers, status + + def prepare_properties(self, ctx, config): + app_id = config.app_id + vid = ctx.vid + uuid = ctx.uuid + custom_logo = config.custom_logo + is_mobile_num = 1 if ctx.is_mobile else 0 + captcha_uri = 'captcha.js?a={}&u={}&v={}&m={}'.format(ctx.block_action, uuid, vid, is_mobile_num) + + if config.first_party and not ctx.is_mobile: + prefix = app_id[2:] + js_client_src = '/{}/{}'.format(prefix, px_constants.CLIENT_FP_PATH) + captcha_src = '/{}/{}/{}'.format(prefix, px_constants.CAPTCHA_FP_PATH, captcha_uri) + host_url = '/{}/{}'.format(prefix, px_constants.XHR_FP_PATH) + else: + js_client_src = '//{}/{}/main.min.js'.format(px_constants.CLIENT_HOST, app_id) + captcha_src = '//{}/{}/{}'.format(px_constants.CAPTCHA_HOST, app_id, captcha_uri) + host_url = px_constants.COLLECTOR_URL.format(app_id.lower()) + + return { + 'refId': uuid, + 'appId': app_id, + 'vid': vid, + 'uuid': uuid, + 'customLogo': custom_logo, + 'cssRef': config.css_ref, + 'jsRef': config.js_ref, + 'logoVisibility': 'visible' if custom_logo is not None else 'hidden', + 'hostUrl': host_url, + 'jsClientSrc': js_client_src, + 'firstPartyEnabled': 'true' if config.first_party else 'false', + 'blockScript': captcha_src + } + + def is_json_response(self, ctx): + headers = ctx.headers + if ctx.block_action is not px_constants.ACTION_RATELIMIT: + for item in headers.keys(): + if item.lower() == 'accept' or item.lower() == 'content-type': + item_arr = headers[item].split(',') + for header_item in item_arr: + if header_item.strip() == 'application/json': + return True + return False + + +def parse_action(action): + if 'b' == action: + return 'block' + elif 'j' == action: + return 'challege' + elif 'r' == action: + return 'ratelimit' + else: + return 'captcha' diff --git a/perimeterx/px_captcha.py b/perimeterx/px_captcha.py deleted file mode 100644 index 489bd7e..0000000 --- a/perimeterx/px_captcha.py +++ /dev/null @@ -1,46 +0,0 @@ -import px_httpc - -def verify(ctx, config, captcha): - if not captcha: - return False - - split_captcha = captcha.split(':') - - if not len(split_captcha) == 3: - return False - - captcha_value = split_captcha[0] - vid = split_captcha[1] - uuid = split_captcha[2] - - if not vid or not captcha_value or not uuid: - return False - - ctx['uuid'] = uuid; - - response = send_captcha_request(vid, uuid, captcha_value, ctx, config) - return response and response.get('status', 1) == 0 - -def send_captcha_request(vid, uuid, captcha_value, ctx, config): - body = { - 'request': { - 'ip': ctx.get('socket_ip'), - 'headers': format_headers(ctx.get('headers')), - 'uri': ctx.get('uri') - }, - 'pxCaptcha': captcha_value, - 'vid': vid, - 'uuid': uuid, - 'hostname': ctx.get('hostname') - } - response = px_httpc.send('/api/v1/risk/captcha', body=body, config=config) - - return response - - -def format_headers(headers): - ret_val = [] - for key in headers.keys(): - ret_val.append({'name': key, 'value': headers[key]}) - return ret_val - diff --git a/perimeterx/px_config.py b/perimeterx/px_config.py new file mode 100644 index 0000000..549471e --- /dev/null +++ b/perimeterx/px_config.py @@ -0,0 +1,190 @@ +import px_constants +from px_logger import Logger + + +class PxConfig(object): + def __init__(self, config_dict): + app_id = config_dict.get('app_id') + debug_mode = config_dict.get('debug_mode', False) + module_mode = config_dict.get('module_mode', px_constants.MODULE_MODE_MONITORING) + custom_logo = config_dict.get('custom_logo', None) + self._px_app_id = app_id + self._blocking_score = config_dict.get('blocking_score', 100) + self._debug_mode = debug_mode + self._module_version = config_dict.get('module_version', px_constants.MODULE_VERSION) + self._module_mode = module_mode + self._server_host = 'sapi.perimeterx.net' if app_id is None else px_constants.SERVER_URL.format(app_id.lower()) + self._collector_host = 'collector.perimeterx.net' if app_id is None else px_constants.COLLECTOR_URL.format( + app_id.lower()) + self._encryption_enabled = config_dict.get('encryption_enabled', True) + self._sensitive_headers = config_dict.get('sensitive_headers', ['cookie', 'cookies']) + self._send_page_activities = config_dict.get('send_page_activities', True) + self._api_timeout_ms = config_dict.get('api_timeout', 500) + self._custom_logo = custom_logo + self._css_ref = config_dict.get('_custom_logo', '') + self._js_ref = config_dict.get('js_ref', '') + self._is_mobile = config_dict.get('is_mobile', False) + self._monitor_mode = 0 if module_mode is px_constants.MODULE_MODE_MONITORING else 1 + self._module_enabled = config_dict.get('module_enabled', True) + self._auth_token = config_dict.get('auth_token', None) + self._is_mobile = config_dict.get('is_mobile', False) + self._first_party = config_dict.get('first_party', True) + self._first_party_xhr_enabled = config_dict.get('first_party_xhr_enabled', True) + self._ip_headers = config_dict.get('ip_headers', []) + self._proxy_url = config_dict.get('proxy_url', None) + self._max_buffer_len = config_dict.get('max_buffer_len', 30) + self._sensitive_routes = config_dict.get('sensitive_routes', []) + self._whitelist_routes = config_dict.get('whitelist_routes', []) + self._block_html = 'BLOCK' + self._logo_visibility = 'visible' if custom_logo is not None else 'hidden' + self._telemetry_config = self.__create_telemetry_config() + + self._auth_token = config_dict.get('auth_token', None) + self._cookie_key = config_dict.get('cookie_key', None) + self.__instantiate_user_defined_handlers(config_dict) + self._logger = Logger(debug_mode, app_id) + + @property + def module_mode(self): + return self._module_mode + + @property + def app_id(self): + return self._px_app_id + + @property + def logger(self): + return self._logger + + @property + def auth_token(self): + return self._auth_token + + @property + def cookie_key(self): + return self._cookie_key + + @property + def server_host(self): + return self._server_host + + @property + def api_timeout(self): + return self._api_timeout_ms / 1000.000 + + @property + def module_enabled(self): + return self._module_enabled + + @property + def ip_headers(self): + return self._ip_headers + + @property + def sensitive_headers(self): + return self._sensitive_headers + + @property + def proxy_url(self): + return self._proxy_url + + @property + def custom_request_handler(self): + return self._custom_request_handler + + @property + def blocking_score(self): + return self._blocking_score + + @property + def encryption_enabled(self): + return self._encryption_enabled + + @property + def module_version(self): + return self._module_version + + @property + def send_page_activities(self): + return self._send_page_activities + + @property + def custom_logo(self): + return self._custom_logo + + @property + def css_ref(self): + return self._css_ref + + @property + def js_ref(self): + return self._js_ref + + @property + def first_party(self): + return self._first_party + + @property + def first_party_xhr_enabled(self): + return self._first_party_xhr_enabled + + @property + def collector_host(self): + return self._collector_host + + @property + def get_user_ip(self): + return self._get_user_ip + + @property + def sensitive_routes(self): + return self._sensitive_routes + + @property + def whitelist_routes(self): + return self._whitelist_routes + + @property + def block_html(self): + return self._block_html + + @property + def logo_visibility(self): + return self._logo_visibility + + @property + def additional_activity_handler(self): + return self._additional_activity_handler + + @property + def debug_mode(self): + return self._debug_mode + + @property + def max_buffer_len(self): + return self._max_buffer_len + + @property + def telemetry_config(self): + return self._telemetry_config + + @property + def enrich_custom_parameters(self): + return self._enrich_custom_parameters + + def __instantiate_user_defined_handlers(self, config_dict): + self._custom_request_handler = self.__set_handler('custom_request_handler', config_dict) + self._get_user_ip = self.__set_handler('get_user_ip', config_dict) + self._additional_activity_handler = self.__set_handler('additional_activity_handler', config_dict) + self._enrich_custom_parameters = self.__set_handler('enrich_custom_parameters', config_dict) + + def __set_handler(self, function_name, config_dict): + return config_dict.get(function_name) if config_dict.get(function_name) and callable( + config_dict.get(function_name)) else None + + def __create_telemetry_config(self): + config = self.__dict__ + mutated_config = {} + for key, value in config.iteritems(): + mutated_config[key[1:].upper()] = value + return mutated_config diff --git a/perimeterx/px_constants.py b/perimeterx/px_constants.py new file mode 100644 index 0000000..5fcc489 --- /dev/null +++ b/perimeterx/px_constants.py @@ -0,0 +1,38 @@ +PREFIX_PX_COOKIE_V1 = '_px' +PREFIX_PX_COOKIE_V3 = '_px3' +PREFIX_PX_TOKEN_V1 = '1' +PREFIX_PX_TOKEN_V3 = '3' +MOBILE_SDK_HEADER = "x-px-authorization" +MOBILE_SDK_ORIGINAL_HEADER= "x-px-original-token" + +TRANS_5C = b"".join(chr(x ^ 0x5C) for x in range(256)) +TRANS_36 = b"".join(chr(x ^ 0x36) for x in range(256)) + +BLOCK_TEMPLATE = 'block_template.mustache' +RATELIMIT_TEMPLATE = 'ratelimit.mustache' +CLIENT_HOST = 'client.perimeterx.net' +CAPTCHA_HOST = 'captcha.px-cdn.net' +COLLECTOR_URL = 'collector-{}.perimeterx.net' +SERVER_URL = 'sapi-{}.perimeterx.net' +CLIENT_FP_PATH = 'init.js' +CAPTCHA_FP_PATH = 'captcha' +XHR_FP_PATH = 'xhr' +MODULE_MODE_BLOCKING = 'active_blocking' +MODULE_MODE_MONITORING = 'monitor' +CLIENT_TP_PATH = 'main.min.js' +FIRST_PARTY_HEADER = 'x-px-first-party' +ENFORCER_TRUE_IP_HEADER = 'x-px-enforcer-true-ip' +EMPTY_GIF_B64 = 'R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' +COLLECTOR_HOST = 'collector.perimeterx.net' +FIRST_PARTY_FORWARDED_FOR = 'X-FORWARDED-FOR' +MODULE_VERSION = 'Python WSGI Module v2.0.0' +API_RISK = '/api/v3/risk' +PAGE_REQUESTED_ACTIVITY = 'page_requested' +BLOCK_ACTIVITY = 'block' +API_ENFORCER_TELEMETRY = '/api/v2/risk/telemetry' +API_ACTIVITIES = '/api/v1/collector/s2s' +TELEMETRY_ACTIVITY = 'enforcer_telemetry' +ACTION_CHALLENGE = 'j' +ACTION_BLOCK = 'b' +ACTION_RATELIMIT = 'r' +ACTION_CAPTCHA = 'c' diff --git a/perimeterx/px_context.py b/perimeterx/px_context.py index 869ef2e..dd4e76d 100644 --- a/perimeterx/px_context.py +++ b/perimeterx/px_context.py @@ -1,54 +1,375 @@ import Cookie +from px_constants import * -def build_context(environ, config): - headers = {} - - # Default values - http_method = 'GET' - http_version = '1.1' - http_protocol = 'http://' - px_cookie = '' - uuid = '' - - # IP Extraction - if config.get('ip_handler'): - socket_ip = config.get('ip_handler')(environ) - else: - socket_ip = environ.get('REMOTE_ADDR') - - # Extracting: Headers, user agent, http method, http version - for key in environ.keys(): - if key.startswith('HTTP_') and environ.get(key): - header_name = key.split('HTTP_')[1].replace('_', '-').lower() - if header_name not in config.get('sensitive_headers'): - headers[header_name] = environ.get(key) - if key == 'REQUEST_METHOD': - http_method = environ.get(key) - if key == 'SERVER_PROTOCOL': - protocol_split = environ.get(key, '').split('/') - if protocol_split[0].startswith('HTTP'): - http_protocol = protocol_split[0].lower() + '://' - if len(protocol_split) > 1: - http_version = protocol_split[1] - - cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE', '')) - if cookies.get('_px') and cookies.get('_px').value: - px_cookie = cookies.get('_px').value - - user_agent = headers.get('user-agent') - uri = environ.get('PATH_INFO') or '' - full_url = http_protocol + headers.get('host') or environ.get('SERVER_NAME') or '' + uri - hostname = headers.get('host') - ctx = { - 'headers': headers, - 'http_method': http_method, - 'http_version': http_version, - 'user_agent': user_agent, - 'socket_ip': socket_ip, - 'full_url': full_url, - 'uri': uri, - 'hostname': hostname, - '_px': px_cookie - } - return ctx +class PxContext(object): + + def __init__(self, environ, config): + + logger = config.logger + headers = {} + + # Default values + http_method = '' + http_version = '' + http_protocol = '' + px_cookies = {} + request_cookie_names = [] + cookie_origin = "cookie" + vid = '' + + # Extracting: Headers, user agent, http method, http version + for key in environ.keys(): + if key.startswith('HTTP_') and environ.get(key): + header_name = key.split('HTTP_')[1].replace('_', '-').lower() + if header_name not in config.sensitive_headers: + headers[header_name] = environ.get(key) + if key == 'REQUEST_METHOD': + http_method = environ.get(key) + if key == 'SERVER_PROTOCOL': + protocol_split = environ.get(key, '').split('/') + if protocol_split[0].startswith('HTTP'): + http_protocol = protocol_split[0].lower() + '://' + if len(protocol_split) > 1: + http_version = protocol_split[1] + if key == 'CONTENT_TYPE' or key == 'CONTENT_LENGTH': + headers[key.replace('_', '-').lower()] = environ.get(key) + if key == 'HTTP_' + MOBILE_SDK_HEADER.replace('-', '_').upper(): + headers[MOBILE_SDK_HEADER] = environ.get(key, '') + + original_token = '' + mobile_header = headers.get(MOBILE_SDK_HEADER) + if mobile_header is None: + cookies = Cookie.SimpleCookie(environ.get('HTTP_COOKIE', '')) + cookie_keys = cookies.keys() + + for key in cookie_keys: + request_cookie_names.append(key) + if key == PREFIX_PX_COOKIE_V1 or key == PREFIX_PX_COOKIE_V3: + logger.debug('Found cookie prefix:' + key) + px_cookies[key] = cookies.get(key).value + if '_pxvid' in cookie_keys: + vid = cookies.get('_pxvid').value + else: + cookie_origin = "header" + original_token = headers.get(MOBILE_SDK_ORIGINAL_HEADER) + logger.debug('Mobile SDK token detected') + cookie_name, cookie = self.get_token_object(config, mobile_header) + px_cookies[cookie_name] = cookie + + user_agent = headers.get('user-agent', '') + uri = environ.get('PATH_INFO') or '' + full_url = http_protocol + (headers.get('host') or environ.get('SERVER_NAME') or '') + uri + hostname = headers.get('host') + sensitive_route = len( + filter(lambda sensitive_route_item: uri.startswith(sensitive_route_item), config.sensitive_routes)) > 0 + whitelist_route = len( + filter(lambda whitelist_route_item: uri.startswith(whitelist_route_item), config.whitelist_routes)) > 0 + query_params = environ.get('QUERY_STRING') if environ.get('QUERY_STRING') else '' + self._headers = headers + self._http_method = http_method + self._http_version = http_version + self._user_agent = user_agent + self._full_url = full_url + self._uri = uri + self._hostname = hostname + self._px_cookies = px_cookies + self._cookie_names = request_cookie_names + self._risk_rtt = 0 + self._ip = self.extract_ip(config, environ) + self._vid = vid + self._uuid = '' + self._query_params = query_params + self._sensitive_route = sensitive_route + self._whitelist_route = whitelist_route + self._s2s_call_reason = 'none' + self._cookie_origin = cookie_origin + self._is_mobile = cookie_origin == "header" + self._score = -1 + self._block_reason = '' + self._decoded_cookie = '' + self._block_action = '' + self._block_action_data = '' + self._pass_reason = '' + self._cookie_hmac = '' + self._px_orig_cookie = '' + self._original_token_error = '' + self._original_uuid = '' + self._decoded_original_token = '' + self._original_token = original_token + + + def get_token_object(self, config, token): + result = {} + logger = config.logger + sliced_token = token.split(":", 1) + if len(sliced_token) > 1: + key = sliced_token.pop(0) + if key == PREFIX_PX_TOKEN_V1 or key == PREFIX_PX_TOKEN_V3: + logger.debug('Found token prefix:' + key) + return key, sliced_token[0] + return PREFIX_PX_TOKEN_V3, token + + def extract_ip(self, config, environ): + ip = environ.get('HTTP_X_FORWARDED_FOR') if environ.get('HTTP_X_FORWARDED_FOR') else environ.get('REMOTE_ADDR') + ip_headers = config.ip_headers + logger = config.logger + if ip_headers: + try: + for ip_header in ip_headers: + ip_header_name = 'HTTP_' + ip_header.replace('-', '_').upper() + if environ.get(ip_header_name): + return environ.get(ip_header_name) + except: + logger.debug('Failed to use IP_HEADERS from config') + if config.get_user_ip: + ip = config.get_user_ip(environ) + return ip + + @property + def headers(self): + return self._headers + + @headers.setter + def headers(self, headers): + self._headers = headers + + @property + def http_method(self): + return self._http_method + + @http_method.setter + def http_method(self, http_method): + self._http_method = http_method + + @property + def http_version(self): + return self._http_version + + @http_version.setter + def http_version(self, http_version): + self._http_version = http_version + + @property + def user_agent(self): + return self._user_agent + + @user_agent.setter + def user_agent(self, user_agent): + self._user_agent = user_agent + + @property + def full_url(self): + return self._full_url + + @full_url.setter + def full_url(self, full_url): + self._full_url = full_url + + @property + def uri(self): + return self._uri + + @uri.setter + def uri(self, uri): + self._uri = uri + + @property + def hostname(self): + return self._hostname + + @hostname.setter + def hostname(self, hostname): + self._hostname = hostname + + @property + def px_cookies(self): + return self._px_cookies + + @px_cookies.setter + def px_cookies(self, px_cookies): + self._px_cookies = px_cookies + + @property + def cookie_names(self): + return self._cookie_names + + @cookie_names.setter + def cookie_names(self, cookie_names): + self._cookie_names = cookie_names + + @property + def risk_rtt(self): + return self._risk_rtt + + @risk_rtt.setter + def risk_rtt(self, risk_rtt): + self._risk_rtt = risk_rtt + + @property + def ip(self): + return self._ip + + @ip.setter + def ip(self, ip): + self._ip = ip + + @property + def vid(self): + return self._vid + + @vid.setter + def vid(self, vid): + self._vid = vid + + @property + def query_params(self): + return self._query_params + + @query_params.setter + def query_params(self, query_params): + self._query_params = query_params + + @property + def sensitive_route(self): + return self._sensitive_route + + @sensitive_route.setter + def sensitive_route(self, sensitive_route): + self._sensitive_route = sensitive_route + + @property + def whitelist_route(self): + return self._whitelist_route + + @whitelist_route.setter + def whitelist_route(self, whitelist_route): + self._whitelist_route = whitelist_route + + @property + def s2s_call_reason(self): + return self._s2s_call_reason + + @s2s_call_reason.setter + def s2s_call_reason(self, s2s_call_reason): + self._s2s_call_reason = s2s_call_reason + + @property + def cookie_origin(self): + return self._cookie_origin + + @cookie_origin.setter + def cookie_origin(self, cookie_origin): + self._cookie_origin = cookie_origin + + @property + def original_token(self): + return self._original_token + + @original_token.setter + def original_token(self, original_token): + self._original_token = original_token + + @property + def is_mobile(self): + return self._is_mobile + + @is_mobile.setter + def is_mobile(self, is_mobile): + self._is_mobile = is_mobile + + @property + def score(self): + return self._score + + @score.setter + def score(self, score): + self._score = score + + @property + def uuid(self): + return self._uuid + + @uuid.setter + def uuid(self, uuid): + self._uuid = uuid + + @property + def block_reason(self): + return self._block_reason + + @block_reason.setter + def block_reason(self, block_reason): + self._block_reason = block_reason + + @property + def decoded_cookie(self): + return self._decoded_cookie + + @decoded_cookie.setter + def decoded_cookie(self, decoded_cookie): + self._decoded_cookie = decoded_cookie + + @property + def block_action(self): + return self._block_action + + @block_action.setter + def block_action(self, block_action): + self._block_action = block_action + + @property + def block_action_data(self): + return self._block_action_data + + @block_action_data.setter + def block_action_data(self, block_action_data): + self._block_action_data = block_action_data + + @property + def pass_reason(self): + return self._pass_reason + + @pass_reason.setter + def pass_reason(self, pass_reason): + self._pass_reason = pass_reason + + @property + def cookie_hmac(self): + return self._cookie_hmac + + @cookie_hmac.setter + def cookie_hmac(self, cookie_hmac): + self._cookie_hmac = cookie_hmac + + @property + def px_orig_cookie(self): + return self._px_orig_cookie + + @px_orig_cookie.setter + def px_orig_cookie(self, px_orig_cookie): + self._px_orig_cookie = px_orig_cookie + + @property + def original_token_error(self): + return self._original_token_error + + @original_token_error.setter + def original_token_error(self, original_token_error): + self._original_token_error = original_token_error + + @property + def original_uuid(self): + return self._original_uuid + + @original_uuid.setter + def original_uuid(self, original_uuid): + self._original_uuid = original_uuid + + @property + def decoded_original_token(self): + return self._decoded_original_token + + @decoded_original_token.setter + def decoded_original_token(self, decoded_original_token): + self._decoded_original_token = decoded_original_token diff --git a/perimeterx/px_cookie.py b/perimeterx/px_cookie.py index c3d0043..46738a6 100644 --- a/perimeterx/px_cookie.py +++ b/perimeterx/px_cookie.py @@ -1,201 +1,181 @@ +import json +from px_constants import * from Crypto.Cipher import AES from time import time import base64 import hmac import hashlib -import json -import sys, traceback +import sys +import traceback import binascii import struct -_trans_5C = b"".join(chr(x ^ 0x5C) for x in range(256)) -_trans_36 = b"".join(chr(x ^ 0x36) for x in range(256)) - - -def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None): - """Password based key derivation function 2 (PKCS #5 v2.0) - - This Python implementations based on the hmac module about as fast - as OpenSSL's PKCS5_PBKDF2_HMAC for short passwords and much faster - for long passwords. - """ - if not isinstance(hash_name, str): - raise TypeError(hash_name) - - if not isinstance(password, (bytes, bytearray)): - password = bytes(buffer(password)) - if not isinstance(salt, (bytes, bytearray)): - salt = bytes(buffer(salt)) - - # Fast inline HMAC implementation - inner = hashlib.new(hash_name) - outer = hashlib.new(hash_name) - blocksize = getattr(inner, 'block_size', 64) - if len(password) > blocksize: - password = hashlib.new(hash_name, password).digest() - password = password + b'\x00' * (blocksize - len(password)) - inner.update(password.translate(_trans_36)) - outer.update(password.translate(_trans_5C)) - - def prf(msg, inner=inner, outer=outer): - # PBKDF2_HMAC uses the password as key. We can re-use the same - # digest objects and just update copies to skip initialization. - icpy = inner.copy() - ocpy = outer.copy() - icpy.update(msg) - ocpy.update(icpy.digest()) - return ocpy.digest() - - if iterations < 1: - raise ValueError(iterations) - if dklen is None: - dklen = outer.digest_size - if dklen < 1: - raise ValueError(dklen) - - hex_format_string = "%%0%ix" % (hashlib.new(hash_name).digest_size * 2) - - dkey = b'' - loop = 1 - while len(dkey) < dklen: - prev = prf(salt + struct.pack(b'>I', loop)) - rkey = int(binascii.hexlify(prev), 16) - for i in xrange(iterations - 1): - prev = prf(prev) - rkey ^= int(binascii.hexlify(prev), 16) - loop += 1 - dkey += binascii.unhexlify(hex_format_string % rkey) - - return dkey[:dklen] - - -def is_cookie_expired(cookie): - """ - Checks if cookie validity time expired. - :param cookie: risk object - :type cookie: dictionary - :return: Returns True if valid and False if not - :rtype: Bool - """ - now = int(round(time() * 1000)) - expire = cookie[u't'] - return now > expire - - -def is_cookie_valid(cookie, cookie_key, ctx): - """ - Checks if cookie hmac signing match the request. - :param cookie: risk object - :param cookie_key: cookie secret key - :param ctx: perimeterx request context object - :type cookie: dictionary - :type cookie_key: string - :type ctx: dictionary - :return: Returns True if valid and False if not - :rtype: Bool - """ - user_agent = ctx['user_agent'] - msg = str(cookie['t']) + str(cookie['s']['a']) + str(cookie['s']['b']) + str(cookie['u']) + str( - cookie['v']) + user_agent - - valid_digest = cookie['h'] - try: - calculated_digest = hmac.new(cookie_key, msg, hashlib.sha256).hexdigest() - except: - return False - - return valid_digest == calculated_digest - - -def decrypt_cookie(cookie_key, cookie): - """ - Decrypting the PerimeterX risk cookie using AES - :param cookie_key: cookie secret key - :param cookie: risk cookie - encrypted - :type cookie_key: string - :type cookie: string - :return: Returns decrypted value if valid and False if not - :rtype: Bool|String - """ - try: - parts = cookie.split(':', 3) - if len(parts) != 3: - return False - salt = base64.b64decode(parts[0]) - iterations = int(parts[1]) - if iterations < 1 or iterations > 10000: - return False - data = base64.b64decode(parts[2]) - dk = pbkdf2_hmac('sha256', cookie_key, salt, iterations, dklen=48) - key = dk[:32] - iv = dk[32:] - cipher = AES.new(key, AES.MODE_CBC, iv) - unpad = lambda s: s[0:-ord(s[-1])] - plaintext = unpad(cipher.decrypt(data)) - return plaintext - except: - print traceback.format_exception(*sys.exc_info()) - return False - - -def verify(ctx, config): - """ - main verification function, verifying the content of the perimeterx risk cookie if exists - :param ctx: perimeterx request context object - :param config: global configurations - :type ctx: dictionary - :type config: dictionary - :return: Returns True if verification succeeded and False if not - :rtype: Bool - """ - logger = config['logger'] - px_cookie = ctx['_px'] - try: - if not px_cookie: - logger.debug('No risk cookie on the request') - ctx['s2s_call_reason'] = 'no_cookie' + +class PxCookie(object): + + def __init__(self, config): + self._config = config + self._logger = config.logger + + + def build_px_cookie(self, px_cookies, user_agent=''): + self._logger.debug("PxCookie[build_px_cookie]") + # Check that its not empty + if not px_cookies: + return None + px_cookie_keys = px_cookies.keys() + px_cookie_keys.sort(reverse=True) + prefix = px_cookie_keys[0] + if prefix == PREFIX_PX_TOKEN_V1 or prefix == PREFIX_PX_COOKIE_V1: + self._logger.debug("PxCookie[build_px_cookie] using token v1") + from px_cookie_v1 import PxCookieV1 + return PxCookieV1(self._config, px_cookies[prefix]) + if prefix == PREFIX_PX_TOKEN_V3 or prefix == PREFIX_PX_COOKIE_V3: + self._logger.debug("PxCookie[build_px_cookie] using token v3") + from px_cookie_v3 import PxCookieV3 + ua = '' + if prefix == PREFIX_PX_COOKIE_V3: + ua = user_agent + return PxCookieV3(self._config, px_cookies[prefix], ua) + + def decode_cookie(self): + self._logger.debug("PxCookie[decode_cookie]") + return base64.b64decode(self.raw_cookie) + + def pbkdf2_hmac(self, hash_name, password, salt, iterations, dklen=None): + """Password based key derivation function 2 (PKCS #5 v2.0) + + This Python implementations based on the hmac module about as fast + as OpenSSL's PKCS5_PBKDF2_HMAC for short passwords and much faster + for long passwords. + """ + if not isinstance(hash_name, str): + raise TypeError(hash_name) + + if not isinstance(password, (bytes, bytearray)): + password = bytes(buffer(password)) + if not isinstance(salt, (bytes, bytearray)): + salt = bytes(buffer(salt)) + + # Fast inline HMAC implementation + inner = hashlib.new(hash_name) + outer = hashlib.new(hash_name) + blocksize = getattr(inner, 'block_size', 64) + if len(password) > blocksize: + password = hashlib.new(hash_name, password).digest() + password = password + b'\x00' * (blocksize - len(password)) + inner.update(password.translate(TRANS_36)) + outer.update(password.translate(TRANS_5C)) + + def prf(msg, inner=inner, outer=outer): + # PBKDF2_HMAC uses the password as key. We can re-use the same + # digest objects and just update copies to skip initialization. + icpy = inner.copy() + ocpy = outer.copy() + icpy.update(msg) + ocpy.update(icpy.digest()) + return ocpy.digest() + + if iterations < 1: + raise ValueError(iterations) + if dklen is None: + dklen = outer.digest_size + if dklen < 1: + raise ValueError(dklen) + + hex_format_string = "%%0%ix" % (hashlib.new(hash_name).digest_size * 2) + + dkey = b'' + loop = 1 + while len(dkey) < dklen: + prev = prf(salt + struct.pack(b'>I', loop)) + rkey = int(binascii.hexlify(prev), 16) + for i in xrange(iterations - 1): + prev = prf(prev) + rkey ^= int(binascii.hexlify(prev), 16) + loop += 1 + dkey += binascii.unhexlify(hex_format_string % rkey) + + return dkey[:dklen] + + def decrypt_cookie(self): + """ + Decrypting the PerimeterX risk cookie using AES + :return: Returns decrypted value if valid and False if not + :rtype: Bool|String + """ + self._logger.debug("PxCookie[decrypt_cookie]") + try: + parts = self.raw_cookie.split(':', 3) + if len(parts) != 3: + return False + salt = base64.b64decode(parts[0]) + iterations = int(parts[1]) + if iterations < 1 or iterations > 10000: + return False + data = base64.b64decode(parts[2]) + dk = self.pbkdf2_hmac('sha256', self._config.cookie_key, salt, iterations, dklen=48) + key = dk[:32] + iv = dk[32:] + cipher = AES.new(key, AES.MODE_CBC, iv) + unpad = lambda s: s[0:-ord(s[-1])] + plaintext = unpad(cipher.decrypt(data)) + self._logger.debug("PxCookie[decrypt_cookie] cookie decrypted") + return plaintext + except: + print traceback.format_exception(*sys.exc_info()) + return None + + def is_cookie_expired(self): + """ + Checks if cookie validity time expired. + :return: Returns True if valid and False if not + :rtype: Bool + """ + now = int(round(time() * 1000)) + expire = self.get_timestamp() + return now > expire + + def is_cookie_valid(self, str_to_hmac): + """ + Checks if cookie hmac signing match the request. + :return: Returns True if valid and False if not + :rtype: Bool + """ + try: + calculated_digest = hmac.new(self._config.cookie_key, str_to_hmac, hashlib.sha256).hexdigest() + return self.get_hmac() == calculated_digest + except: + self._logger.debug("failed to calculate hmac") return False - decrypted_cookie = decrypt_cookie(config['cookie_key'], px_cookie) + def deserialize(self): + logger = self._logger + logger.debug("PxCookie[deserialize]") + if self._config.encryption_enabled: + cookie = self.decrypt_cookie() + else: + cookie = self.decode_cookie() - if not decrypted_cookie: - logger.error('Cookie decryption failed') - ctx['px_orig_cookie'] = px_cookie - ctx['s2s_call_reason'] = 'cookie_decryption_failed' + if not cookie: return False - decoded_cookie = json.loads(decrypted_cookie) - try: - decoded_cookie['s'], decoded_cookie['s']['b'], decoded_cookie['u'], decoded_cookie['t'], decoded_cookie['v'] - except: - logger.error('Cookie decryption failed') - ctx['px_orig_cookie'] = px_cookie - ctx['s2s_call_reason'] = 'cookie_decryption_failed' - return False + logger.debug("Original token deserialized : " + cookie) + self.decoded_cookie = json.loads(cookie) + return self.is_cookie_format_valid() - ctx['risk_score'] = decoded_cookie['s']['b'] - ctx['uuid'] = decoded_cookie.get('u', '') - ctx['vid'] = decoded_cookie.get('v', '') - ctx['decoded_cookie'] = decoded_cookie + def is_high_score(self): + return self.get_score() >= self._config.blocking_score + + def get_timestamp(self): + return self.decoded_cookie['t'] + + def get_uuid(self): + return self.decoded_cookie['u'] + + def get_vid(self): + return self.decoded_cookie['v'] - if decoded_cookie['s']['b'] >= config['blocking_score']: - ctx['block_reason'] = 'cookie_high_score' - logger.debug('Cookie with high score: ' + str(ctx['risk_score'])) - return True - if is_cookie_expired(decoded_cookie): - ctx['s2s_call_reason'] = 'cookie_expired' - logger.debug('Cookie expired') - return False - if not is_cookie_valid(decoded_cookie, config['cookie_key'], ctx): - logger.debug('Cookie validation failed') - ctx['s2s_call_reason'] = 'cookie_validation_failed' - return False - logger.debug('Cookie validation passed with good score: ' + str(ctx['risk_score'])) - return True - except: - logger.debug('Cookie validation failed') - ctx['s2s_call_reason'] = 'cookie_validation_failed' - return False diff --git a/perimeterx/px_cookie_v1.py b/perimeterx/px_cookie_v1.py new file mode 100644 index 0000000..a675ea5 --- /dev/null +++ b/perimeterx/px_cookie_v1.py @@ -0,0 +1,33 @@ +from px_cookie import PxCookie +from px_constants import * + + +class PxCookieV1(PxCookie): + + def __init__(self, config, raw_cookie): + self._config = config + self._logger = config.logger + self.raw_cookie = raw_cookie + + def get_score(self): + return self.decoded_cookie['s']['b'] + + def get_hmac(self): + return self.decoded_cookie['h'] + + def get_action(self): + return 'c' + + def is_cookie_format_valid(self): + c = self.decoded_cookie + return 't' in c and 'v' in c and 'u' in c and "s" in c and 'a' in c['s'] and 'h' in c + + def is_secured(self, user_agent, ip): + c = self.decoded_cookie + base_hmac = str(self.get_timestamp()) + str(c['s']['a']) + str(self.get_score()) + self.get_uuid() + self.get_vid() + hmac_with_ip = base_hmac + ip + user_agent + hmac_without_ip = base_hmac + user_agent + + return self.is_cookie_valid(hmac_without_ip) or self.is_cookie_valid(hmac_with_ip) + + diff --git a/perimeterx/px_cookie_v3.py b/perimeterx/px_cookie_v3.py new file mode 100644 index 0000000..65ab594 --- /dev/null +++ b/perimeterx/px_cookie_v3.py @@ -0,0 +1,34 @@ +from px_cookie import PxCookie + + +class PxCookieV3(PxCookie): + + def __init__(self, config, cookie, user_agent): + self._config = config + self._logger = config.logger + self._user_agent = user_agent + spliced_cookie = cookie.split(':') + if len(spliced_cookie) is 4: + self.hmac = spliced_cookie[0] + self.raw_cookie = ':'.join(spliced_cookie[1:]) + else: + self.raw_cookie = cookie + + def get_score(self): + return self.decoded_cookie['s'] + + def get_hmac(self): + return self.hmac + + def get_action(self): + return self.decoded_cookie['a'] + + def is_cookie_format_valid(self): + c = self.decoded_cookie + return 't' in c and 'v' in c and 'u' in c and 's' in c and 'a' in c + + def is_secured(self): + user_agent = self._user_agent + str_hmac = self.raw_cookie + user_agent + return self.is_cookie_valid(str_hmac) + diff --git a/perimeterx/px_cookie_validator.py b/perimeterx/px_cookie_validator.py new file mode 100644 index 0000000..d62ada8 --- /dev/null +++ b/perimeterx/px_cookie_validator.py @@ -0,0 +1,81 @@ +import traceback +import re +import px_original_token_validator +from px_cookie import PxCookie + + +def verify(ctx, config): + """ + main verification function, verifying the content of the perimeterx risk cookie if exists + :param ctx: perimeterx request context object + :param config: global configurations + :type ctx: dictionary + :type config: dictionary + :return: Returns True if verification succeeded and False if not + :rtype: Bool + """ + logger = config.logger + try: + if not ctx.px_cookies.keys(): + logger.debug('No risk cookie on the request') + ctx.s2s_call_reason = 'no_cookie' + return False + + if not config.cookie_key: + logger.debug('No cookie key found, pause cookie evaluation') + ctx['s2s_call_reason'] = 'no_cookie_key' + return False + + px_cookie_builder = PxCookie(config) + px_cookie = px_cookie_builder.build_px_cookie(px_cookies=ctx.px_cookies, + user_agent=ctx.user_agent) + #Mobile SDK traffic + if px_cookie and ctx.is_mobile: + pattern = re.compile("^\d+$") + if re.match(pattern, px_cookie.raw_cookie): + ctx.s2s_call_reason = "mobile_error_" + px_cookie.raw_cookie + if ctx.original_token is not None: + px_original_token_validator.verify(ctx, config) + return False + + if not px_cookie.deserialize(): + logger.error('Cookie decryption failed') + ctx.px_orig_cookie = px_cookie.raw_cookie + ctx.s2s_call_reason = 'cookie_decryption_failed' + return False + + ctx.score = px_cookie.get_score() + ctx.uuid = px_cookie.get_uuid() + ctx.vid = px_cookie.get_vid() + ctx.decoded_cookie = px_cookie.decoded_cookie + ctx.cookie_hmac = px_cookie.get_hmac() + ctx.block_action = px_cookie.get_action() + + if px_cookie.is_high_score(): + ctx.block_reason = 'cookie_high_score' + logger.debug('Cookie with high score: ' + str(ctx.score)) + return True + + if px_cookie.is_cookie_expired(): + ctx.s2s_call_reason = 'cookie_expired' + logger.debug('Cookie expired') + return False + + if not px_cookie.is_secured(): + logger.debug('Cookie validation failed') + ctx.s2s_call_reason = 'cookie_validation_failed' + return False + + if ctx.sensitive_route: + logger.debug('Sensitive route match, sending Risk API. path: {}'.format(ctx.uri)) + ctx.s2s_call_reason = 'sensitive_route' + return False + + logger.debug('Cookie validation passed with good score: ' + str(ctx.score)) + return True + except Exception, e: + traceback.print_exc() + logger.debug('Could not decrypt cookie, exception was thrown, decryption failed ' + e.message) + ctx.px_orig_cookie = px_cookie.raw_cookie + ctx.s2s_call_reason = 'cookie_decryption_failed' + return False diff --git a/perimeterx/px_httpc.py b/perimeterx/px_httpc.py index 33282ee..28e92d9 100644 --- a/perimeterx/px_httpc.py +++ b/perimeterx/px_httpc.py @@ -1,34 +1,22 @@ -import httplib -import json import time +import requests -http_client = None - -def init(config): - global http_client - http_client = httplib.HTTPConnection(config.get('perimeterx_server_host'), timeout=config.get('api_timeout', 1)) - - -def send(uri, body, config): - logger = config['logger'] - headers = { - 'Authorization': 'Bearer ' + config.get('auth_token', ''), - 'Content-Type': 'application/json' - } +def send(full_url, body, headers, config, method): + logger = config.logger try: start = time.time() - http_client.request('POST', uri, body=json.dumps(body), headers=headers) - r = http_client.getresponse() + if method == 'GET': + response = requests.get(url='https://' + full_url, headers=headers, timeout=config.api_timeout, stream=True) + else: + response = requests.post(url='https://' + full_url, headers=headers, data=body, timeout=config.api_timeout) - if r.status != 200: - logger.error('error posting server to server call ' + r.reason) + if response.status_code >= 400: + logger.debug('PerimeterX server call failed') return False - logger.debug('Server call took ' + str(time.time() - start) + 'ms') - response_body = r.read() - - return json.loads(response_body) - except httplib.HTTPException: - init(config) + logger.debug('PerimeterX server call took ' + str(time.time() - start) + 'ms') + return response + except requests.exceptions.RequestException as e: + logger.debug('Received RequestException, message: ' + e.message) return False diff --git a/perimeterx/px_logger.py b/perimeterx/px_logger.py index 873c34c..0de8c6e 100644 --- a/perimeterx/px_logger.py +++ b/perimeterx/px_logger.py @@ -1,10 +1,11 @@ class Logger(object): - def __init__(self, debug=False): + def __init__(self, debug, app_id): self.debug_mode = debug + self.app_id = app_id def debug(self, message): if self.debug_mode: - print '[PerimeterX DEBUG]: ' + message + print '[PerimeterX DEBUG][{}]: '.format(self.app_id) + message def error(self, message): - print '[PerimeterX ERROR]: ' + message + print '[PerimeterX ERROR][{}]: '.format(self.app_id) + message diff --git a/perimeterx/px_original_token_validator.py b/perimeterx/px_original_token_validator.py new file mode 100644 index 0000000..84a17f9 --- /dev/null +++ b/perimeterx/px_original_token_validator.py @@ -0,0 +1,39 @@ + +from px_cookie import PxCookie + +def verify(ctx, config): + """ + main verification function, verifying the content of the perimeterx original token risk if exists + :param ctx: perimeterx request context object + :param config: global configurations + :type ctx: dictionary + :type config: dictionary + :return: Returns True if verification succeeded and False if not + :rtype: Bool + """ + logger = config.logger + try: + logger.debug('Original token found, Evaluating') + original_token = ctx.original_token + version, no_version_token = original_token.split(':', 1) + px_cookie_builder = PxCookie(config) + px_cookie = px_cookie_builder.build_px_cookie({version: no_version_token}, '') + + if not px_cookie.deserialize(): + logger.error('Original token decryption failed, value:' + px_cookie.raw_cookie) + ctx.original_token_error = 'decryption_failed' + return False + + ctx.decoded_original_token = px_cookie.decoded_cookie + ctx.vid = px_cookie.get_vid() + ctx.original_uuid = px_cookie.get_uuid() + if not px_cookie.is_secured(): + logger.debug('Original token HMAC validation failed, value: ' + str(px_cookie.decoded_cookie)) + ctx.original_token_error = 'validation_failed' + return False + return True + + except Exception, e: + logger.debug('Could not decrypt original token, exception was thrown, decryption failed ' + e.message) + ctx.original_token_error = 'decryption_failed' + return False diff --git a/perimeterx/px_proxy.py b/perimeterx/px_proxy.py new file mode 100644 index 0000000..f0d31af --- /dev/null +++ b/perimeterx/px_proxy.py @@ -0,0 +1,138 @@ +import px_constants +import px_httpc +import px_utils +import base64 + +hoppish = {'connection', 'keep-alive', 'proxy-authenticate', + 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', + 'upgrade' + } + + +def delete_extra_headers(filtered_headers): + if 'content-length' in filtered_headers.keys(): + del filtered_headers['content-length'] + if 'content-type' in filtered_headers.keys(): + del filtered_headers['content-type'] + + +class PXProxy(object): + def __init__(self, config): + self._logger = config.logger + + reverse_app_id = config.app_id[2:] + self.client_reverse_prefix = '/{}/{}'.format(reverse_app_id, px_constants.CLIENT_FP_PATH).lower() + self.xhr_reverse_prefix = '/{}/{}'.format(reverse_app_id, px_constants.XHR_FP_PATH).lower() + self.captcha_reverse_prefix = '/{}/{}'.format(reverse_app_id, px_constants.CAPTCHA_FP_PATH).lower() + + def should_reverse_request(self, uri): + uri = uri.lower() + if uri.startswith(self.client_reverse_prefix) or uri.startswith(self.xhr_reverse_prefix) or uri.startswith( + self.captcha_reverse_prefix): + return True + return False + + def handle_reverse_request(self, config, ctx, start_response, body): + uri = ctx.uri.lower() + + if uri.startswith(self.client_reverse_prefix): + return self.send_reverse_client_request(config=config, ctx=ctx, start_response=start_response) + if uri.startswith(self.xhr_reverse_prefix): + return self.send_reverse_xhr_request(config=config, ctx=ctx, start_response=start_response, body=body) + if uri.startswith(self.captcha_reverse_prefix): + return self.send_reverse_captcha_request(config=config, ctx=ctx, start_response=start_response) + + def send_reverse_client_request(self, config, ctx, start_response): + if not config.first_party: + headers = [('Content-Type', 'application/javascript')] + start_response("200 OK", headers) + return "" + + client_request_uri = '/{}/main.min.js'.format(config.app_id) + self._logger.debug( + 'Forwarding request from {} to client at {}{}'.format(ctx.uri.lower(), px_constants.CLIENT_HOST, + client_request_uri)) + + headers = {'host': px_constants.CLIENT_HOST, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip} + filtered_headers = px_utils.handle_proxy_headers(ctx.headers, ctx.ip) + filtered_headers = px_utils.merge_two_dicts(filtered_headers, headers) + delete_extra_headers(filtered_headers) + response = px_httpc.send(full_url=px_constants.CLIENT_HOST + client_request_uri, body='', + headers=filtered_headers, config=config, method='GET') + + self.handle_proxy_response(response, start_response) + return response.raw.read() + + def send_reverse_xhr_request(self, config, ctx, start_response, body): + uri = ctx.uri + if not config.first_party or not config.first_party_xhr_enabled: + body, content_type = self.return_default_response(uri) + + start_response('200 OK', [content_type]) + return body + + xhr_path_index = uri.find('/' + px_constants.XHR_FP_PATH) + suffix_uri = uri[xhr_path_index + 4:] + + host = config.collector_host + headers = {'host': host, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip} + + if ctx.vid is not None: + headers['Cookies'] = '_pxvid=' + ctx.vid + + filtered_headers = px_utils.handle_proxy_headers(ctx.headers, ctx.ip) + filtered_headers = px_utils.merge_two_dicts(filtered_headers, headers) + self._logger.debug( + 'Forwarding request from {} to client at {}{}'.format(ctx.uri.lower(), host, suffix_uri)) + response = px_httpc.send(full_url=host + suffix_uri, body=body, + headers=filtered_headers, config=config, method=ctx.http_method) + + if response.status_code >= 400: + body, content_type = self.return_default_response(uri) + self._logger.debug('error reversing the http call ' + response.reason) + start_response('200 OK', [content_type]) + return body + self.handle_proxy_response(response, start_response) + return response.content + + def handle_proxy_response(self, response, start_response): + headers = [] + for header in response.headers: + if header.lower() not in hoppish: + headers.append((header, response.headers[header])) + start_response(str(response.status_code) + ' ' + response.reason, headers) + + def return_default_response(self, uri): + if 'gif' in uri.lower(): + content_type = tuple('Content-Type', 'image/gif') + body = base64.b64decode(px_constants.EMPTY_GIF_B64) + else: + content_type = tuple('Content-Type', 'application/json') + body = {} + return body, content_type + + def send_reverse_captcha_request(self, config, ctx, start_response): + if not config.first_party: + status = '200 OK' + headers = [('Content-Type', 'application/javascript')] + start_response(status, headers) + return '' + uri = '/{}{}?{}'.format(config.app_id, ctx.uri.lower().replace(self.captcha_reverse_prefix, ''), + ctx.query_params) + host = px_constants.CAPTCHA_HOST + + headers = {'host': px_constants.CAPTCHA_HOST, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip} + filtered_headers = px_utils.handle_proxy_headers(ctx.headers, ctx.ip) + filtered_headers = px_utils.merge_two_dicts(filtered_headers, headers) + delete_extra_headers(filtered_headers) + self._logger.debug('Forwarding request from {} to client at {}{}'.format(ctx.uri.lower(), host, uri)) + response = px_httpc.send(full_url=host + uri, body='', + headers=filtered_headers, config=config, method='GET') + self.handle_proxy_response(response, start_response) + return response.raw.read() diff --git a/perimeterx/px_template.py b/perimeterx/px_template.py index a01c0dd..b299f43 100644 --- a/perimeterx/px_template.py +++ b/perimeterx/px_template.py @@ -1,29 +1,16 @@ -import pystache import os -def get_template(template, config, uuid, vid): - template_content = get_content(template) - props = get_props(config, uuid, vid) - generatedHtml = pystache.render(template_content, props) - return generatedHtml def get_path(): return os.path.dirname(os.path.abspath(__file__)) def get_content(template): - templatePath = "%s/templates/%s.mustache" % (get_path(),template) + templatePath = "%s/templates/%s" % (get_path(), template) file = open(templatePath, "r") content = file.read() return content -def get_props(config, uuid, vid): - return { - 'refId': uuid, - 'appId': config.get('app_id'), - 'vid': vid, - 'uuid': uuid, - 'customLogo': config.get('custom_logo'), - 'cssRef': config.get('css_ref'), - 'jsRef': config.get('js_ref'), - 'logoVisibility': 'visible' if config['custom_logo'] else 'hidden' - } + + +def get_template(template_name): + return get_content(template_name) diff --git a/perimeterx/px_utils.py b/perimeterx/px_utils.py new file mode 100644 index 0000000..dd14177 --- /dev/null +++ b/perimeterx/px_utils.py @@ -0,0 +1,29 @@ +import px_constants + + +def merge_two_dicts(x, y): + z = x.copy() # start with x's keys and values + z.update(y) # modifies z with y's keys and values & returns None + return z + + +def handle_proxy_headers(filtered_headers, ip): + for item in filtered_headers.keys(): + if item.upper() == px_constants.FIRST_PARTY_FORWARDED_FOR: + filtered_headers[item] = ip + else: + filtered_headers[px_constants.FIRST_PARTY_FORWARDED_FOR] = ip + return filtered_headers + + +def is_static_file(ctx): + uri = ctx.uri + static_extensions = ['.css', '.bmp', '.tif', '.ttf', '.docx', '.woff2', '.js', '.pict', '.tiff', '.eot', + '.xlsx', '.jpg', '.csv', '.eps', '.woff', '.xls', '.jpeg', '.doc', '.ejs', '.otf', '.pptx', + '.gif', '.pdf', '.swf', '.svg', '.ps', '.ico', '.pls', '.midi', '.svgz', '.class', '.png', + '.ppt', '.mid', 'webp', '.jar'] + + for ext in static_extensions: + if uri.endswith(ext): + return True + return False \ No newline at end of file diff --git a/perimeterx/templates/block.mustache b/perimeterx/templates/block.mustache deleted file mode 100644 index b61c371..0000000 --- a/perimeterx/templates/block.mustache +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - Access to this page has been denied. - - - - {{# cssRef }} - - {{/ cssRef }} - - -
-
- -
-
-
-

Access to this page has been denied.

-
-
-
-
-

- You have been blocked because we believe you are using automation tools to browse the website. -

-

- Please note that Javascript and Cookies must be enabled on your browser to access the website. -

-

- If you think you have been blocked by mistake, please contact the website administrator with the reference ID below. -

-

- Reference ID: #{{refId}} -

-
-
- -
- - - - {{# jsRef }} - - {{/ jsRef }} - - diff --git a/perimeterx/templates/block_template.mustache b/perimeterx/templates/block_template.mustache new file mode 100644 index 0000000..3fcef19 --- /dev/null +++ b/perimeterx/templates/block_template.mustache @@ -0,0 +1,175 @@ + + + + + + Access to this page has been denied. + + + + {{#cssRef}} + + {{/cssRef}} + + + +
+
+ +
+
+
+

Please verify you are a human

+
+
+
+
+ +
+
+

+ Access to this page has been denied because we believe you are using automation tools to browse the + website. +

+

+ This may happen as a result of the following: +

+
    +
  • + Javascript is disabled or blocked by an extension (ad blockers for example) +
  • +
  • + Your browser does not support cookies +
  • +
+

+ Please make sure that Javascript and cookies are enabled on your browser and that you are not blocking + them from loading. +

+

+ Reference ID: #{{refId}} +

+
+
+ +
+ + + + + +{{#jsRef}} + +{{/jsRef}} + + diff --git a/perimeterx/templates/captcha.mustache b/perimeterx/templates/captcha.mustache deleted file mode 100644 index 2ede096..0000000 --- a/perimeterx/templates/captcha.mustache +++ /dev/null @@ -1,185 +0,0 @@ - - - - - - Access to this page has been denied. - - - - {{#cssRef}} - - {{/cssRef}} - - - - -
-
- -
-
-
-

Please verify you are a human

-
-
-
-
-

- Please click "I am not a robot" to continue -

-
-
-

- Access to this page has been denied because we believe you are using automation tools to browse the website. -

-

- This may happen as a result of the following: -

-
    -
  • - Javascript is disabled or blocked by an extension (ad blockers for example) -
  • -
  • - Your browser does not support cookies -
  • -
-

- Please make sure that Javascript and cookies are enabled on your browser and that you are not blocking them from loading. -

-

- Reference ID: #{{refId}} -

-
-
- -
- - - - - - {{#jsRef}} - - {{/jsRef}} - - diff --git a/perimeterx/templates/ratelimit.mustache b/perimeterx/templates/ratelimit.mustache new file mode 100644 index 0000000..36fd393 --- /dev/null +++ b/perimeterx/templates/ratelimit.mustache @@ -0,0 +1,9 @@ + + + Too Many Requests + + +

Too Many Requests

+

Reached maximum requests limitation, try again soon.

+ + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dea6cd4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +pystache==0.5.4 +mock==2.0.0 +requests==2.20.1 +requests_mock==1.5.2 +setuptools==40.6.2 +pycrypto==2.6.1 +pylint diff --git a/setup.py b/setup.py index fe498fb..6b06b37 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,10 @@ from setuptools import setup -version = 'v1.2.0' +version = 'v2.0.0' setup(name='perimeterx-python-wsgi', version=version, + license='MIT', description='PerimeterX WSGI middleware', author='Ben Diamant', author_email='ben@perimeterx.com', @@ -12,6 +13,8 @@ download_url='https://github.com/PerimeterX/perimeterx-python-wsgi/tarball/' + version, package_dir={'perimeterx': 'perimeterx'}, install_requires=[ - "pystache" - ] - ) + "pystache==0.5.4", 'requests==2.20.1', 'setuptools==40.6.2', 'requests_mock==1.5.2', + 'pycrypto==2.6.1', 'mock==2.0.0', 'pylint'], + classifiers=['Intended Audience :: Developers', + 'Programming Language :: Python :: 2.7']) + diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/px_blocking_messages/blocking.txt b/test/px_blocking_messages/blocking.txt new file mode 100644 index 0000000..21071c1 --- /dev/null +++ b/test/px_blocking_messages/blocking.txt @@ -0,0 +1,169 @@ + + + + + + Access to this page has been denied. + + + + + + +
+
+ +
+
+
+

Please verify you are a human

+
+
+
+
+ +
+
+

+ Access to this page has been denied because we believe you are using automation tools to browse the + website. +

+

+ This may happen as a result of the following: +

+
    +
  • + Javascript is disabled or blocked by an extension (ad blockers for example) +
  • +
  • + Your browser does not support cookies +
  • +
+

+ Please make sure that Javascript and cookies are enabled on your browser and that you are not blocking + them from loading. +

+

+ Reference ID: #8712cef7-bcfa-4bb6-ae99-868025e1908a +

+
+
+ +
+ + + + + + + diff --git a/test/px_blocking_messages/ratelimit.txt b/test/px_blocking_messages/ratelimit.txt new file mode 100644 index 0000000..36fd393 --- /dev/null +++ b/test/px_blocking_messages/ratelimit.txt @@ -0,0 +1,9 @@ + + + Too Many Requests + + +

Too Many Requests

+

Reached maximum requests limitation, try again soon.

+ + \ No newline at end of file diff --git a/test/test_px_activities_client.py b/test/test_px_activities_client.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_px_api.py b/test/test_px_api.py new file mode 100644 index 0000000..fe56440 --- /dev/null +++ b/test/test_px_api.py @@ -0,0 +1,44 @@ +import unittest +from perimeterx import px_api +from perimeterx.px_config import PxConfig +from perimeterx.px_context import PxContext +import mock +import uuid +import json + +class Test_PXApi(unittest.TestCase): + + + + def enrich_custom_parameters(self, params): + params['custom_param1'] = '1' + params['custom_param2'] = '5' + params['custom'] = '6' + return params + + def test_prepare_risk_body(self): + config = PxConfig({'app_id': 'app_id', 'enrich_custom_parameters': self.enrich_custom_parameters}) + ctx = PxContext({},config) + ctx.s2s_call_reason = 'no_cookie' + body = px_api.prepare_risk_body(ctx, config) + self.assertEqual(body['additional'].get('custom_param1'), '1') + self.assertEqual(body['additional'].get('custom_param2'), '5') + self.assertFalse(body['additional'].get('custom')) + + def test_send_risk_request(self): + config = PxConfig({'app_id': 'app_id', + 'enrich_custom_parameters': self.enrich_custom_parameters, + 'auth_token': 'auth'}) + ctx = PxContext({'PATH_INFO': '/test_path'}, config) + uuid_val = str(uuid.uuid4()) + response = ResponseMock({'score': 100, 'uuid': uuid_val, 'action': 'c'}) + with mock.patch('perimeterx.px_httpc.send', return_value=response): + response = px_api.send_risk_request(ctx, config) + self.assertEqual({'action': 'c', 'score': 100, 'uuid': uuid_val}, response) + + + +class ResponseMock(object): + def __init__(self, dict): + self.content = json.dumps(dict) + diff --git a/test/test_px_blocker.py b/test/test_px_blocker.py new file mode 100644 index 0000000..6d36b83 --- /dev/null +++ b/test/test_px_blocker.py @@ -0,0 +1,116 @@ +from perimeterx.px_blocker import PXBlocker + +import os +import unittest +from perimeterx.px_config import PxConfig +from perimeterx.px_context import PxContext + + +class Test_PXBlocker(unittest.TestCase): + + def test_is_json_response(self): + px_blocker = PXBlocker() + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_ACCEPT': 'text/html'}, + config) + + self.assertFalse(px_blocker.is_json_response(ctx)) + ctx.headers['Accept'] = 'application/json' + self.assertTrue(px_blocker.is_json_response(ctx)) + + def test_handle_blocking(self): + px_blocker = PXBlocker() + vid = 'bf619be8-94be-458a-b6b1-ee81f154c282' + px_uuid = '8712cef7-bcfa-4bb6-ae99-868025e1908a' + + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_ACCEPT': 'text/html'}, + config) + ctx.vid = vid + ctx.uuid = px_uuid + px_config = PxConfig({'app_id': 'PXfake_app_ip'}) + message, _, _ = px_blocker.handle_blocking(ctx, px_config) + working_dir = os.path.dirname(os.path.realpath(__file__)) + with open(working_dir + '/px_blocking_messages/blocking.txt', 'r') as myfile: + blocking_message = myfile.read() + + self.assertEqual(message, blocking_message) + + def test_handle_ratelimit(self): + px_blocker = PXBlocker() + vid = 'bf619be8-94be-458a-b6b1-ee81f154c282' + px_uuid = '8712cef7-bcfa-4bb6-ae99-868025e1908a' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_ACCEPT': 'text/html'}, + config) + ctx.vid = vid + ctx.uuid = px_uuid + ctx.block_action = 'r' + message, _, _ = px_blocker.handle_blocking(ctx, config) + blocking_message = None + working_dir = os.path.dirname(os.path.realpath(__file__)) + with open(working_dir + '/px_blocking_messages/ratelimit.txt', 'r') as myfile: + blocking_message = myfile.read() + self.assertEqual(message, blocking_message) + + def test_handle_challenge(self): + px_blocker = PXBlocker() + vid = 'bf619be8-94be-458a-b6b1-ee81f154c282' + px_uuid = '8712cef7-bcfa-4bb6-ae99-868025e1908a' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_ACCEPT': 'text/html'}, + config) + ctx.vid = vid + ctx.uuid = px_uuid + ctx.block_action = 'j' + ctx.block_action_data = 'Bla' + + message, _, _ = px_blocker.handle_blocking(ctx, config) + blocking_message = 'Bla' + self.assertEqual(message, blocking_message) + + def test_prepare_properties(self): + px_blocker = PXBlocker() + vid = 'bf619be8-94be-458a-b6b1-ee81f154c282' + px_uuid = '8712cef7-bcfa-4bb6-ae99-868025e1908a' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/xhr', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_ACCEPT': 'text/html'}, + config) + ctx.vid = vid + ctx.uuid = px_uuid + message = px_blocker.prepare_properties(ctx, config) + expected_message = {'blockScript': '/fake_app_id/captcha/captcha.js?a=&u=8712cef7-bcfa-4bb6-ae99-868025e1908a&v=bf619be8-94be-458a-b6b1-ee81f154c282&m=0', + 'vid': 'bf619be8-94be-458a-b6b1-ee81f154c282', + 'jsRef': '', + 'hostUrl': '/fake_app_id/xhr', + 'customLogo': None, + 'appId': 'PXfake_app_id', + 'uuid': '8712cef7-bcfa-4bb6-ae99-868025e1908a', + 'logoVisibility': 'hidden', + 'jsClientSrc': '/fake_app_id/init.js', + 'firstPartyEnabled': 'true', + 'refId': '8712cef7-bcfa-4bb6-ae99-868025e1908a', + 'cssRef': ''} + self.assertDictEqual(message, expected_message) + expected_message['blockScript'] = '/fake_app/captcha/captcha.js?a=&u=8712cef7-bcfa-4bb6-ae99-868025e1908a&v=bf619be8-94be-458a-b6b1-ee81f154c282&m=0' + self.assertNotEqual(message, expected_message) + + + + + diff --git a/test/test_px_config.py b/test/test_px_config.py new file mode 100644 index 0000000..6116cdc --- /dev/null +++ b/test/test_px_config.py @@ -0,0 +1,12 @@ +from perimeterx.px_config import PxConfig +import unittest +from perimeterx import px_constants +class TestPXConfig(unittest.TestCase): + + def test_constructor(self): + config_dict = {'app_id': 'PXfake_app_id', 'debug_mode': True, 'module_mode': px_constants.MODULE_MODE_BLOCKING} + config = PxConfig(config_dict) + self.assertEqual(config._monitor_mode, 1) + self.assertEqual(config.debug_mode, True) + self.assertEqual(config.server_host, 'sapi-pxfake_app_id.perimeterx.net') + self.assertEqual(config.collector_host, 'collector-pxfake_app_id.perimeterx.net') \ No newline at end of file diff --git a/test/test_px_context.py b/test/test_px_context.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_px_cookie.py b/test/test_px_cookie.py new file mode 100644 index 0000000..2cfb5d6 --- /dev/null +++ b/test/test_px_cookie.py @@ -0,0 +1,38 @@ + +import unittest +from perimeterx.px_cookie import PxCookie +from perimeterx.px_config import PxConfig + +class TestPXCookie(unittest.TestCase): + + @classmethod + def setUpClass(cls): + config = PxConfig({'app_id': 'fake_app_id'}) + cls.px_cookie = PxCookie(config) + cls.config = config + + def test_build_cookie(self): + px_cookies = {'_px3':'bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA='} + cookie = self.px_cookie.build_px_cookie(px_cookies=px_cookies, user_agent='') + self.assertEqual('bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd', cookie.hmac) + self.assertEqual('OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA=', cookie.raw_cookie) + + px_cookies = {'_px3':'OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA='} + cookie = self.px_cookie.build_px_cookie(px_cookies=px_cookies, user_agent='') + if hasattr(cookie, 'hmac'): + self.assertFalse(True) + + def test_build_token(self): + px_cookies = {'3':'bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA='} + cookie = self.px_cookie.build_px_cookie(px_cookies=px_cookies, user_agent='') + self.assertEqual('bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd', cookie.hmac) + self.assertEqual('OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA=', cookie.raw_cookie) + + px_cookies = {'3':'OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA='} + cookie = self.px_cookie.build_px_cookie(px_cookies=px_cookies, user_agent='') + if hasattr(cookie, 'hmac'): + self.assertFalse(True) + + + + diff --git a/test/test_px_cookie_validator.py b/test/test_px_cookie_validator.py new file mode 100644 index 0000000..6ec5f0f --- /dev/null +++ b/test/test_px_cookie_validator.py @@ -0,0 +1,82 @@ +from perimeterx import px_cookie_validator +import unittest +from perimeterx.px_config import PxConfig +from perimeterx.px_context import PxContext + + +class Test_PXCookieValidator(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.cookie_key = 'Pyth0nS3crE7K3Y' + cls.config = PxConfig({'app_id': 'app_id', + 'cookie_key': cls.cookie_key}) + + def test_verify_no_cookie(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1'}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertFalse(verified) + self.assertEqual('no_cookie', ctx.s2s_call_reason) + + def test_verify_valid_cookie(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_COOKIE': '_px3=bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA='}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertTrue(verified) + self.assertEqual('none', ctx.s2s_call_reason) + + def test_verify_decryption_failed(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_COOKIE': '_px3=774958bcc233ea1a876b92ababf47086d8a4d95165bbd6f98b55d7e61afd2a05:ow3Er5dskpt8ZZ11CRiDMAueEi3ozJTqMBnYzsSM7/8vHTDA0so6ekhruiTrXa/taZINotR5PnTo78D5zM2pWw==:1000:uQ3Tdt7D3mSO5CuHDis3GgrnkGMC+XAghbHuNOE9x4H57RAmtxkTcNQ1DaqL8rx79bHl0iPVYlOcRmRgDiBCUoizBdUCjsSIplofPBLIl8WpfHDDtpxPKzz9I2rUEbFgfhFjiTY3rPGob2PUvTsDXTfPUeHnzKqbNTO8z7H6irFnUE='}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertFalse(verified) + self.assertEqual('cookie_decryption_failed', ctx.s2s_call_reason) + + def test_verify_cookie_high_score(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_COOKIE': '_px3=bf46ceff75278ae166f376cbf741a7639060581035dd4e93641892c905dd0d67:EGFGcwQ2rum7KRmQCeSXBAUt1+25mj2DFJYi7KJkEliF3cBspdXtD2X03Csv8N8B6S5Bte/4ccCcETkBNDVxTw==:1000:x9x+oI6BISFhlKEERpf8HpZD2zXBCW9lzVfuRURHaAnbaMnpii+XjPEd7a7EGGUSMch5ramy3y+KOxyuX3F+LbGYwvn3OJb+u40zU+ixT1w5N15QltX+nBMhC7izC1l8QtgMuG/f3Nts5ebnec9j2V7LS5Y1/5b73rd9s7AMnug='}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertTrue(verified) + self.assertEqual('none', ctx.s2s_call_reason) + + def test_verify_hmac_validation(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_COOKIE': '_px3=774958bcc232343ea1a876b92ababf47086d8a4d95165bbd6f98b55d7e61afd2a05:ow3Er5dskpt8ZZ11CRiDMAueEi3ozJTqMBnYzsSM7/8vHTDA0so6ekhruiTrXa/taZINotR5PnTo78D5zM2pWw==:1000:uQ3Tdt7D3mSO5CuHDis3GgrnkGMC+XAghbHuNOE9x4H57RAmtxkTcNQ1DaqL8rx79bHl0iPVYlOcRmRgDiBCUoizBdUCjsSIplofPBLIl8WpfHDDtpxPKzz9I2rUEbFFjiTY3rPGob2PUvTsDXTfPUeHnzKqbNTO8z7H6irFnUE='}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertFalse(verified) + self.assertEqual('cookie_validation_failed', ctx.s2s_call_reason) + + def test_verify_expired_cookie(self): + config = self.config + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'HTTP_COOKIE': '_px3=0d67bdf4a58c524b55b9cf0f703e4f0f3cbe23a10bd2671530d3c7e0cfa509eb:HOiYSw11ICB2A+HYx+C+l5Naxcl7hMeEo67QNghCQByyHlhWZT571ZKfqV98JFWg7TvbV9QtlrQtXakPYeIEjQ==:1000:+kuXS/iJUoEqrm8Fo4K0cTebsc4YQZu+f5bRGX0lC1T+l0g1gzRUuKiCtWTar28Y0wjch1ZQvkNy523Pxr07agVi/RL0SUktmEl59qGor+m4FLewZBVdcgx/Ya9kU0riis98AAR0zdTpTtoN5wpNbmztIpOZ0YejeD0Esk3vagU='}, + PxConfig({'app_id': 'fake_app_id'})) + verified = px_cookie_validator.verify(ctx, config) + self.assertFalse(verified) + self.assertEqual('cookie_expired', ctx.s2s_call_reason) + + + + diff --git a/test/test_px_httpc.py b/test/test_px_httpc.py new file mode 100644 index 0000000..9083463 --- /dev/null +++ b/test/test_px_httpc.py @@ -0,0 +1,23 @@ +import unittest +from perimeterx import px_httpc +import requests_mock +from perimeterx.px_config import PxConfig + + +class TestPXHttpc(unittest.TestCase): + def test_send(self): + with requests_mock.mock() as m: + config = PxConfig({'app_id': 'PXfake_app_id'}) + full_url = 'this_url.com/uri' + method = 'POST' + body = 'content to post' + + headers = {'content-type': 'application/json'} + + m.post('https://' + full_url) + response = px_httpc.send(full_url=full_url, config=config,method=method,body=body,headers=headers) + m.called + m.get('https://' + full_url) + method = 'GET' + response = px_httpc.send(full_url=full_url, config=config,method=method,body=body,headers=headers) + self.assertEqual(m.call_count, 2) \ No newline at end of file diff --git a/test/test_px_original_token_validator.py b/test/test_px_original_token_validator.py new file mode 100644 index 0000000..8524c94 --- /dev/null +++ b/test/test_px_original_token_validator.py @@ -0,0 +1,41 @@ +import unittest +from perimeterx import px_original_token_validator +from perimeterx.px_config import PxConfig +from perimeterx.px_context import PxContext +from perimeterx import px_constants +class TestPXOriginalTokenValidator(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.cookie_key = 'Pyth0nS3crE7K3Y' + cls.config = PxConfig({'app_id': 'app_id', + 'cookie_key': cls.cookie_key}) + def test_verify(self): + token = '3:bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA=' + + context = PxContext({'HTTP_' + px_constants.MOBILE_SDK_ORIGINAL_HEADER.replace('-', '_').upper(): token, + 'HTTP_' + px_constants.MOBILE_SDK_HEADER.replace('-', '_').upper(): '2'}, self.config) + verified = px_original_token_validator.verify(context, self.config) + self.assertTrue(verified) + self.assertEqual(context.vid, 'ce305f10-f17e-11e8-90f2-e7a14f96c498') + self.assertEqual(context.decoded_original_token, {'a': 'a', 's': 0, 'u': 'ce308620-f17e-11e8-90f2-e7a14f96c498', 't': 1663653730456, 'v': 'ce305f10-f17e-11e8-90f2-e7a14f96c498'}) + self.assertEqual(context.original_uuid, 'ce308620-f17e-11e8-90f2-e7a14f96c498') + self.assertEqual(context.original_token_error, '') + + def test_decryption_error_token(self): + token = '3:bd078865fa9627f626d6f7d6828ab595028d2c0974065ab6f6c5a9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00asfafu4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA=' + + context = PxContext({'HTTP_' + px_constants.MOBILE_SDK_ORIGINAL_HEADER.replace('-', '_').upper(): token, + 'HTTP_' + px_constants.MOBILE_SDK_HEADER.replace('-', '_').upper(): '2'}, self.config) + verified = px_original_token_validator.verify(context, self.config) + self.assertFalse(verified) + self.assertEqual(context.original_token_error, 'decryption_failed') + + def test_validation_error_token(self): + token = '3:bd078865fa9627f626d6f7d6828ab595028d2c0974ds065ab6f6c5afsaa9f80c4593cd:OCIluokZHHvqrWyu8zrWSH8Vu7AefCjrd4CMx/NXsX58LzeV40EZIlPG4gsNMoAYzH88s/GoZwv+DpQa76C21A==:1000:zwT+Rht/YGDNWKkzHtJAB7IiI00u4fOePL/3xWMs1nZ93lzW1XvAMGR2hLlHBmOv8O0CpylEQOZZTK1uQMls6O28Y8aQnTo5DETLkrbhpwCVeNjOcf8GVKTckITwuHfXbEcfHbdtb68s1+jHv1+vt/w/6HZqTzanaIsvFVp8vmA=' + + context = PxContext({'HTTP_' + px_constants.MOBILE_SDK_ORIGINAL_HEADER.replace('-', '_').upper(): token, + 'HTTP_' + px_constants.MOBILE_SDK_HEADER.replace('-', '_').upper(): '2'}, self.config) + verified = px_original_token_validator.verify(context, self.config) + self.assertFalse(verified) + self.assertEqual(context.original_token_error, 'validation_failed') diff --git a/test/test_px_proxy.py b/test/test_px_proxy.py new file mode 100644 index 0000000..f2bd9db --- /dev/null +++ b/test/test_px_proxy.py @@ -0,0 +1,75 @@ +import unittest +import requests_mock +from perimeterx.px_proxy import PXProxy +from perimeterx.px_config import PxConfig +from perimeterx import px_constants +from perimeterx.px_context import PxContext + + +class Test_PXProxy(unittest.TestCase): + + def test_should_reverse_request(self): + config = PxConfig({'app_id': 'PXfake_app_id'}) + px_proxy = PXProxy(config) + should_reverse = px_proxy.should_reverse_request('/fake_app_id/init.js') + self.assertTrue(should_reverse) + should_reverse = px_proxy.should_reverse_request('/fake_app_id/xhr') + self.assertTrue(should_reverse) + should_reverse = px_proxy.should_reverse_request('/fake_app_id/captcha') + self.assertTrue(should_reverse) + + @requests_mock.mock() + def test_send_reverse_client_request(self, mock): + content = 'client js content' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/init.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1'}, + config) + headers = {'host': px_constants.CLIENT_HOST, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip, + px_constants.FIRST_PARTY_FORWARDED_FOR: '127.0.0.1'} + mock.get(url='https://client.perimeterx.net/PXfake_app_id/main.min.js', text=content, request_headers=headers, + status_code=200, reason='OK') + px_proxy = PXProxy(config) + response_body = px_proxy.handle_reverse_request(config=config, ctx=ctx, start_response=lambda x, y: x, body='') + self.assertEqual(content, response_body) + + @requests_mock.mock() + def test_send_reverse_captcha_request(self, mock): + content = 'captcha js content' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/captcha/captcha.js', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1', + 'QUERY_STRING': 'a=c&u=cfe74220-f484-11e8-9b14-d7280325a290&v=0701bb80-f482-11e8-8a31-a37cf9620569&m=0'}, + PxConfig({'app_id': 'fake_app_id'})) + headers = {'host': px_constants.CAPTCHA_HOST, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip, + px_constants.FIRST_PARTY_FORWARDED_FOR: '127.0.0.1'} + mock.get( + url='https://captcha.px-cdn.net/PXfake_app_id/captcha.js?a=c&u=cfe74220-f484-11e8-9b14-d7280325a290&v=0701bb80-f482-11e8-8a31-a37cf9620569&m=0', + text=content, request_headers=headers, status_code=200, reason='OK') + px_proxy = PXProxy(config) + response_body = px_proxy.handle_reverse_request(config=config, ctx=ctx, start_response=lambda x, y: x, body='') + self.assertEqual(content, response_body) + + @requests_mock.mock() + def test_send_reverse_xhr_request(self, mock): + content = 'captcha content' + config = PxConfig({'app_id': 'PXfake_app_id'}) + ctx = PxContext({'PATH_INFO': '/fake_app_id/xhr/api/v1/collector', + 'HTTP_X_FORWARDED_FOR': '127.0.0.1', + 'ip': '127.0.0.1'}, + PxConfig({'app_id': 'fake_app_id'})) + headers = {'host': config.collector_host, + px_constants.FIRST_PARTY_HEADER: '1', + px_constants.ENFORCER_TRUE_IP_HEADER: ctx.ip, + px_constants.FIRST_PARTY_FORWARDED_FOR: '127.0.0.1'} + mock.post(url='https://collector-pxfake_app_id.perimeterx.net/api/v1/collector', text=content, + request_headers=headers, status_code=200, reason='OK') + px_proxy = PXProxy(config) + response_body = px_proxy.handle_reverse_request(config=config, ctx=ctx, start_response=lambda x, y: x, body='') + self.assertEqual(content, response_body) diff --git a/test/test_px_utils.py b/test/test_px_utils.py new file mode 100644 index 0000000..c0959d1 --- /dev/null +++ b/test/test_px_utils.py @@ -0,0 +1,28 @@ +from perimeterx import px_utils +import unittest +from perimeterx import px_constants +from perimeterx.px_context import PxContext +from perimeterx.px_config import PxConfig + +class Test_PXUtils(unittest.TestCase): + + def test_merge_two_dicts(self): + dict1 = {'a': '1'} + dict2 = {'b': '2'} + merged_dict = px_utils.merge_two_dicts(dict1, dict2) + self.assertDictEqual(merged_dict, {'a': '1', 'b': '2'}) + + def test_handle_proxy_headers(self): + headers_sample = {'ddd': 'not_proxy_url', px_constants.FIRST_PARTY_FORWARDED_FOR: 'proxy_url'} + headers_sample = px_utils.handle_proxy_headers(headers_sample, '127.0.0.1') + self.assertEqual(headers_sample[px_constants.FIRST_PARTY_FORWARDED_FOR], '127.0.0.1') + headers_sample = {'ddd': 'not_proxy_url'} + headers_sample = px_utils.handle_proxy_headers(headers_sample, '127.0.0.1') + self.assertEqual(headers_sample[px_constants.FIRST_PARTY_FORWARDED_FOR], '127.0.0.1') + + def test_is_static_file(self): + config = PxConfig({'app_id' : 'fake_app_id'}) + ctx = PxContext({'PATH_INFO': '/sample.css'}, config) + self.assertTrue(px_utils.is_static_file(ctx)) + ctx = PxContext({'PATH_INFO': '/sample.html'}, config) + self.assertFalse(px_utils.is_static_file(ctx))