├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── dockerpublish.yml │ ├── pythonpackage.yml │ └── pythonpublish.yml ├── .gitignore ├── .readthedocs.yml ├── AUTHORS ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.RU.md ├── README.md ├── docs ├── en │ └── plugins │ │ ├── add_header_content_type.md │ │ ├── addheadermultiline.md │ │ ├── addheaderredefinition.md │ │ ├── aliastraversal.md │ │ ├── allow_without_deny.md │ │ ├── error_log_off.md │ │ ├── hostspoofing.md │ │ ├── httpsplitting.md │ │ ├── if_is_evil.md │ │ ├── origins.md │ │ ├── resolver_external.md │ │ ├── ssrf.md │ │ ├── unanchored_regex.md │ │ ├── validreferers.md │ │ ├── version_disclosure.md │ │ └── worker_rlimit_nofile_vs_connections.md ├── gixy.png ├── index.md ├── logo.png ├── requirements.txt └── ru │ └── plugins │ ├── addheadermultiline.md │ ├── addheaderredefinition.md │ ├── aliastraversal.md │ ├── hostspoofing.md │ ├── httpsplitting.md │ ├── origins.md │ ├── ssrf.md │ └── validreferers.md ├── gixy ├── __init__.py ├── __main__.py ├── cli │ ├── __init__.py │ ├── __main__.py │ ├── argparser.py │ └── main.py ├── core │ ├── __init__.py │ ├── builtin_variables.py │ ├── config.py │ ├── context.py │ ├── exceptions.py │ ├── issue.py │ ├── manager.py │ ├── plugins_manager.py │ ├── regexp.py │ ├── severity.py │ ├── sre_parse │ │ ├── __init__.py │ │ ├── sre_constants.py │ │ └── sre_parse.py │ ├── utils.py │ └── variable.py ├── directives │ ├── __init__.py │ ├── block.py │ └── directive.py ├── formatters │ ├── __init__.py │ ├── _jinja.py │ ├── base.py │ ├── console.py │ ├── json.py │ ├── templates │ │ ├── console.j2 │ │ └── text.j2 │ └── text.py ├── parser │ ├── __init__.py │ ├── nginx_parser.py │ └── raw_parser.py ├── plugins │ ├── __init__.py │ ├── add_header_content_type.py │ ├── add_header_multiline.py │ ├── add_header_redefinition.py │ ├── alias_traversal.py │ ├── allow_without_deny.py │ ├── error_log_off.py │ ├── host_spoofing.py │ ├── http_splitting.py │ ├── if_is_evil.py │ ├── origins.py │ ├── plugin.py │ ├── proxy_pass_normalized.py │ ├── regex_redos.py │ ├── resolver_external.py │ ├── ssrf.py │ ├── try_files_is_evil_too.py │ ├── unanchored_regex.py │ ├── valid_referers.py │ ├── version_disclosure.py │ └── worker_rlimit_nofile_vs_connections.py └── utils │ ├── __init__.py │ └── text.py ├── mkdocs.yml ├── requirements.dev.txt ├── requirements.txt ├── rpm ├── gixy.spec └── python-argparse.spec ├── setup.py ├── tests ├── __init__.py ├── cli │ ├── __init__.py │ └── test_cli.py ├── core │ ├── __init__.py │ ├── test_context.py │ ├── test_regexp.py │ └── test_variable.py ├── directives │ ├── __init__.py │ ├── test_block.py │ └── test_directive.py ├── parser │ ├── __init__.py │ ├── test_nginx_parser.py │ └── test_raw_parser.py ├── plugins │ ├── __init__.py │ ├── simply │ │ ├── add_header_content_type │ │ │ ├── add_header_content_type.conf │ │ │ └── add_header_content_type_fp.conf │ │ ├── add_header_multiline │ │ │ ├── add_header.conf │ │ │ ├── add_header_fp.conf │ │ │ ├── config.json │ │ │ ├── more_set_headers.conf │ │ │ ├── more_set_headers_fp.conf │ │ │ ├── more_set_headers_multiple.conf │ │ │ ├── more_set_headers_replace.conf │ │ │ ├── more_set_headers_replace_fp.conf │ │ │ ├── more_set_headers_status_fp.conf │ │ │ └── more_set_headers_type_fp.conf │ │ ├── add_header_redefinition │ │ │ ├── config.json │ │ │ ├── duplicate_fp.conf │ │ │ ├── if_replaces.conf │ │ │ ├── location_replaces.conf │ │ │ ├── nested_block.conf │ │ │ ├── non_block_fp.conf │ │ │ ├── not_secure_dropped.conf │ │ │ ├── not_secure_outer.conf │ │ │ └── step_replaces.conf │ │ ├── alias_traversal │ │ │ ├── config.json │ │ │ ├── nested.conf │ │ │ ├── nested_fp.conf │ │ │ ├── not_slashed_alias.conf │ │ │ ├── not_slashed_alias_fp.conf │ │ │ ├── simple.conf │ │ │ ├── simple_fp.conf │ │ │ ├── slashed_alias.conf │ │ │ └── slashed_alias_fp.conf │ │ ├── allow_without_deny │ │ │ ├── allow_without_deny.conf │ │ │ └── allow_without_deny_fp.conf │ │ ├── error_log_off │ │ │ ├── error_log_off.conf │ │ │ └── error_log_off_fp.conf │ │ ├── host_spoofing │ │ │ ├── config.json │ │ │ ├── http_fp.conf │ │ │ ├── http_host.conf │ │ │ ├── http_host_diff_case.conf │ │ │ └── some_arg.conf │ │ ├── http_splitting │ │ │ ├── add_header_uri.conf │ │ │ ├── config.json │ │ │ ├── dont_report_not_resolved_var_fp.conf │ │ │ ├── proxy_from_location_var.conf │ │ │ ├── proxy_from_location_var_var.conf │ │ │ ├── proxy_from_location_var_var_fp.conf │ │ │ ├── proxy_from_location_var_var_var.conf │ │ │ ├── proxy_pass_cr_fp.conf │ │ │ ├── proxy_pass_ducument_uri.conf │ │ │ ├── proxy_pass_lf.conf │ │ │ ├── proxy_set_header_ducument_uri.conf │ │ │ ├── return_403_fp.conf │ │ │ ├── return_request_uri_fp.conf │ │ │ ├── rewrite_extract_fp.conf │ │ │ ├── rewrite_uri.conf │ │ │ └── rewrite_uri_after_var.conf │ │ ├── if_is_evil │ │ │ ├── config.json │ │ │ ├── if_is_evil_add_header.conf │ │ │ └── if_is_evil_fp.conf │ │ ├── origins │ │ │ ├── config.json │ │ │ ├── metrika.conf │ │ │ ├── origin.conf │ │ │ ├── origin_fp.conf │ │ │ ├── origin_https.conf │ │ │ ├── origin_https_fp.conf │ │ │ ├── origin_w_slash_anchored_fp.conf │ │ │ ├── origin_w_slash_fp.conf │ │ │ ├── origin_wo_slash.conf │ │ │ ├── referer.conf │ │ │ ├── referer_fp.conf │ │ │ ├── referer_subdomain.conf │ │ │ ├── referer_subdomain_fp.conf │ │ │ ├── structure_dot.conf │ │ │ ├── structure_fp.conf │ │ │ ├── structure_prefix.conf │ │ │ ├── structure_suffix.conf │ │ │ └── webvisor.conf │ │ ├── proxy_pass_normalized │ │ │ ├── missing_variable.conf │ │ │ ├── missing_variable_fp.conf │ │ │ ├── missing_variable_nopath.conf │ │ │ ├── missing_variable_nopath_fp.conf │ │ │ ├── proxy_pass_path.conf │ │ │ ├── proxy_pass_path_fp.conf │ │ │ ├── proxy_pass_socket_fp.conf │ │ │ ├── proxy_pass_socket_with_path.conf │ │ │ ├── proxy_pass_var_fp.conf │ │ │ ├── rewrite_with_return_fp.conf │ │ │ ├── variable.conf │ │ │ └── variable_fp.conf │ │ ├── resolver_external │ │ │ ├── resolver_external.conf │ │ │ ├── resolver_external_fp.conf │ │ │ ├── resolver_local_fp.conf │ │ │ ├── resolver_local_ipv6_fp.conf │ │ │ └── resolver_local_ipv6_with_port_fp.conf │ │ ├── rewrite_with_return.conf │ │ ├── ssrf │ │ │ ├── config.json │ │ │ ├── have_internal_fp.conf │ │ │ ├── host_w_const_start.conf │ │ │ ├── host_w_const_start_arg.conf │ │ │ ├── not_host_var_fp.conf │ │ │ ├── request_uri_fp.conf │ │ │ ├── request_uri_var_fp.conf │ │ │ ├── scheme_var.conf │ │ │ ├── single_var.conf │ │ │ ├── used_arg.conf │ │ │ ├── vars_from_loc.conf │ │ │ └── with_const_scheme.conf │ │ ├── try_files_is_evil_too │ │ │ ├── config.json │ │ │ ├── try_files_is_evil_too.conf │ │ │ ├── try_files_is_evil_too_cache_none.conf │ │ │ └── try_files_is_evil_too_fp.conf │ │ ├── unanchored_regex │ │ │ ├── unanchored_regex.conf │ │ │ └── unanchored_regex_fp.conf │ │ ├── valid_referers │ │ │ ├── config.json │ │ │ ├── none_first.conf │ │ │ ├── none_last.conf │ │ │ ├── none_middle.conf │ │ │ └── wo_none_fp.conf │ │ ├── version_disclosure │ │ │ ├── server_tokens_off_fp.conf │ │ │ └── server_tokens_on.conf │ │ └── worker_rlimit_nofile_vs_connections │ │ │ ├── worker_rlimit_nofile_vs_connections_fp.conf │ │ │ ├── worker_rlimit_nofile_vs_connections_missing.conf │ │ │ └── worker_rlimit_nofile_vs_connections_too_low.conf │ └── test_simply.py └── utils.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | **/__pycache__/ 3 | **/*.py[cod] 4 | 5 | # C extensions 6 | ***/*.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | 27 | # Installer logs 28 | pip-log.txt 29 | pip-delete-this-directory.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | cover 40 | 41 | # Translations 42 | **/*.mo 43 | **/*.pot 44 | 45 | # PyBuilder 46 | target/ 47 | 48 | venv/ 49 | venv3/ 50 | .idea/ 51 | 52 | # 100% unnecessary for docker image 53 | .* 54 | *.md 55 | docs 56 | rpm 57 | Dockerfile 58 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_file = lf 5 | insert_final_newline = true 6 | 7 | [*.{py,j2}] 8 | charset = utf-8 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: dvershinin 4 | -------------------------------------------------------------------------------- /.github/workflows/dockerpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that GitHub does not certify. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | release: 14 | types: [published] 15 | push: 16 | branches: 17 | - master 18 | 19 | jobs: 20 | push_to_registries: 21 | name: Push Docker image to multiple registries 22 | runs-on: ubuntu-latest 23 | permissions: 24 | packages: write 25 | contents: read 26 | steps: 27 | - name: Check out the repo 28 | uses: actions/checkout@v3 29 | 30 | - name: Log in to Docker Hub 31 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 32 | with: 33 | username: ${{ secrets.DOCKER_USERNAME }} 34 | password: ${{ secrets.DOCKER_PASSWORD }} 35 | 36 | - name: Log in to the Container registry 37 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 38 | with: 39 | registry: ghcr.io 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Set up QEMU 44 | uses: docker/setup-qemu-action@v2 45 | with: 46 | platforms: all 47 | 48 | - name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v2 50 | 51 | - name: Extract metadata (tags, labels) for Docker 52 | id: meta 53 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 54 | with: 55 | images: | 56 | getpagespeed/gixy 57 | ghcr.io/${{ github.repository }} 58 | 59 | - name: Build and push Docker images 60 | uses: docker/build-push-action@v3.3.0 61 | with: 62 | context: . 63 | push: true 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | platforms: linux/amd64,linux/arm64 67 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | legacy: 7 | name: Test on Python ${{ matrix.python-version }} 8 | runs-on: ubuntu-22.04 9 | strategy: 10 | matrix: 11 | python-version: ['3.6', '3.7'] 12 | container: 13 | image: python:${{ matrix.python-version }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Install dependencies & test 17 | run: | 18 | pip install -r requirements.txt \ 19 | -r requirements.dev.txt 20 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 21 | pytest -v -n auto 22 | modern: 23 | name: Test on Python ${{ matrix.python-version }} 24 | runs-on: ubuntu-22.04 25 | strategy: 26 | max-parallel: 4 27 | matrix: 28 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v3 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install dependencies 36 | run: | 37 | pip install -r requirements.txt 38 | pip install -r requirements.dev.txt 39 | - name: Lint with flake8 40 | run: | 41 | # stop the build if there are Python syntax errors or undefined names 42 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 43 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 44 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 45 | - name: Test with pytest 46 | run: | 47 | pytest -v -n auto 48 | env: 49 | GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v3 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | !rpm/*.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | cover 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | venv/ 62 | venv3/ 63 | .idea/ 64 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | mkdocs: 4 | configuration: mkdocs.yml 5 | 6 | # Set the version of Python and other tools you might need 7 | build: 8 | os: ubuntu-20.04 9 | tools: 10 | python: "3.10" 11 | 12 | python: 13 | install: 14 | - requirements: docs/requirements.txt 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - docs 19 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | The following authors have created the source code of "Gixy" 2 | published and distributed by YANDEX LLC as the owner: 3 | 4 | Andrew Krasichkov -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | ADD . /src 4 | 5 | WORKDIR /src 6 | 7 | RUN pip install --upgrade pip setuptools wheel 8 | RUN python3 setup.py install 9 | 10 | ENTRYPOINT ["gixy"] 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include gixy/formatters/templates/* 2 | graft tests 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all build publish 2 | 3 | all: build publish 4 | 5 | build: 6 | python setup.py bdist_wheel --universal sdist 7 | 8 | publish: 9 | twine upload dist/gixy_ng-`grep -oP "(?<=version\s=\s['\"])[^'\"]*(?=['\"])" gixy/__init__.py`* 10 | 11 | -------------------------------------------------------------------------------- /README.RU.md: -------------------------------------------------------------------------------- 1 | GIXY 2 | ==== 3 | [![Mozilla Public License 2.0](https://img.shields.io/github/license/dvershinin/gixy.svg?style=flat-square)](https://github.com/dvershinin/gixy/blob/master/LICENSE) 4 | [![Build Status](https://img.shields.io/travis/dvershinin/gixy.svg?style=flat-square)](https://travis-ci.org/dvershinin/gixy) 5 | [![Your feedback is greatly appreciated](https://img.shields.io/maintenance/yes/2018.svg?style=flat-square)](https://github.com/dvershinin/gixy/issues/new) 6 | [![GitHub issues](https://img.shields.io/github/issues/dvershinin/gixy.svg?style=flat-square)](https://github.com/dvershinin/gixy/issues) 7 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/dvershinin/gixy.svg?style=flat-square)](https://github.com/dvershinin/gixy/pulls) 8 | 9 | # Overview 10 | Gixy logo 11 | 12 | Gixy — это утилита для анализа конфигурации Nginx. 13 | Большей частью служит для обнаружения проблем безопасности, но может искать и иные ошибки. 14 | 15 | Официально поддерживаются версии Python >= 3.6. 16 | 17 |   18 | # Что умеет 19 | На текущий момент Gixy способна обнаружить: 20 | 21 | * [[ssrf] Server Side Request Forgery](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/ssrf.md) 22 | * [[http_splitting] HTTP Splitting](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/httpsplitting.md) 23 | * [[origins] Проблемы валидации referrer/origin](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/origins.md) 24 | * [[add_header_redefinition] Переопределение "вышестоящих" заголовков ответа директивой "add_header"](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/addheaderredefinition.md) 25 | * [[host_spoofing] Подделка заголовка запроса Host](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/hostspoofing.md) 26 | * [[valid_referrers] none in valid_referrers](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/validreferers.md) 27 | * [[add_header_multiline] Многострочные заголовки ответа](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/addheadermultiline.md) 28 | * [[alias_traversal] Path traversal при использовании alias](https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/aliastraversal.md) 29 | 30 | Проблемы, которым Gixy только учится, можно найти в [Issues с меткой "new plugin"](https://github.com/dvershinin/gixy/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+plugin%22) 31 | 32 | # Установка 33 | Наиболее простой способ установки Gixy — воспользоваться pip для установки из [PyPI](https://pypi.python.org/pypi/gixy): 34 | ```bash 35 | pip install gixy 36 | ``` 37 | 38 | # Использование 39 | После установки должна стать доступна консольная утилита `gixy`. 40 | По умолчанию Gixy ищет конфигурацию по стандартному пути `/etc/nginx/nginx.conf`, однако вы можете указать специфичное расположение: 41 | ``` 42 | $ gixy /etc/nginx/nginx.conf 43 | 44 | ==================== Results =================== 45 | 46 | Problem: [http_splitting] Possible HTTP-Splitting vulnerability. 47 | Description: Using variables that can contain "\n" may lead to http injection. 48 | Additional info: https://github.com/dvershinin/gixy/wiki/ru/httpsplitting 49 | Reason: At least variable "$action" can contain "\n" 50 | Pseudo config: 51 | include /etc/nginx/sites/default.conf; 52 | 53 | server { 54 | 55 | location ~ /v1/((?[^.]*)\.json)?$ { 56 | add_header X-Action $action; 57 | } 58 | } 59 | 60 | 61 | ==================== Summary =================== 62 | Total issues: 63 | Unspecified: 0 64 | Low: 0 65 | Medium: 0 66 | High: 1 67 | ``` 68 | 69 | Gixy умеет обрабатывать директиву `include` и попробует максимально корректно обработать все зависимости, если что-то пошло не так, можно попробовать запустить `gixy` с флагом `-d` для вывода дополнительной информации. 70 | Все доступные опции: 71 | ``` 72 | $ gixy -h 73 | usage: gixy [-h] [-c CONFIG_FILE] [--write-config CONFIG_OUTPUT_PATH] 74 | [-v] [-l] [-f {console,text,json}] [-o OUTPUT_FILE] [-d] 75 | [--tests TESTS] [--skips SKIPS] [--disable-includes] 76 | [--origins-domains domains] 77 | [--origins-https-only https_only] 78 | [--add-header-redefinition-headers headers] 79 | [nginx.conf] 80 | 81 | Gixy - a Nginx configuration [sec]analyzer 82 | 83 | positional arguments: 84 | nginx.conf Path to nginx.conf, e.g. /etc/nginx/nginx.conf 85 | 86 | optional arguments: 87 | -h, --help show this help message and exit 88 | -c CONFIG_FILE, --config CONFIG_FILE 89 | config file path 90 | --write-config CONFIG_OUTPUT_PATH 91 | takes the current command line args and writes them 92 | out to a config file at the given path, then exits 93 | -v, --version show program's version number and exit 94 | -l, --level Report issues of a given severity level or higher (-l 95 | for LOW, -ll for MEDIUM, -lll for HIGH) 96 | -f {console,text,json}, --format {console,text,json} 97 | Specify output format 98 | -o OUTPUT_FILE, --output OUTPUT_FILE 99 | Write report to file 100 | -d, --debug Turn on debug mode 101 | --tests TESTS Comma-separated list of tests to run 102 | --skips SKIPS Comma-separated list of tests to skip 103 | --disable-includes Disable "include" directive processing 104 | 105 | plugins options: 106 | --origins-domains domains 107 | Default: * 108 | --origins-https-only https_only 109 | Default: False 110 | --add-header-redefinition-headers headers 111 | Default: content-security-policy,x-xss- 112 | protection,x-frame-options,x-content-type- 113 | options,strict-transport-security,cache-control 114 | 115 | 116 | available plugins: 117 | host_spoofing 118 | add_header_multiline 119 | http_splitting 120 | valid_referers 121 | origins 122 | add_header_redefinition 123 | ssrf 124 | ``` 125 | 126 | # Contributing 127 | Contributions to Gixy are always welcome! You can help us in different ways: 128 | * Open an issue with suggestions for improvements and errors you're facing; 129 | * Fork this repository and submit a pull request; 130 | * Improve the documentation. 131 | 132 | Code guidelines: 133 | * Python code style should follow [pep8](https://www.python.org/dev/peps/pep-0008/) standards whenever possible; 134 | * Pull requests with new plugins must have unit tests for them. 135 | -------------------------------------------------------------------------------- /docs/en/plugins/add_header_content_type.md: -------------------------------------------------------------------------------- 1 | # Using `add_header` for setting `Content-Type` 2 | 3 | ## Bad example 4 | 5 | ```nginx 6 | add_header Content-Type text/plain; 7 | ``` 8 | This may result in duplicate `Content-Type` headers if your backend sets it. 9 | 10 | ## Good example 11 | 12 | ```nginx 13 | default_type text/plain; 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/en/plugins/addheadermultiline.md: -------------------------------------------------------------------------------- 1 | # [add_header_multiline] Multiline response headers 2 | 3 | You should avoid using multiline response headers, because: 4 | * they are deprecated (see [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2.4)); 5 | * some HTTP-clients and web browser never supported them (e.g. IE/Edge/Nginx). 6 | 7 | ## How can I find it? 8 | Misconfiguration example: 9 | ```nginx 10 | # https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header 11 | add_header Content-Security-Policy " 12 | default-src: 'none'; 13 | script-src data: https://yastatic.net; 14 | style-src data: https://yastatic.net; 15 | img-src data: https://yastatic.net; 16 | font-src data: https://yastatic.net;"; 17 | 18 | # https://www.nginx.com/resources/wiki/modules/headers_more/ 19 | more_set_headers -t 'text/html text/plain' 20 | 'X-Foo: Bar 21 | multiline'; 22 | ``` 23 | 24 | ## What can I do? 25 | The only solution is to never use multiline response headers. 26 | -------------------------------------------------------------------------------- /docs/en/plugins/addheaderredefinition.md: -------------------------------------------------------------------------------- 1 | # [add_header_redefinition] Redefining of response headers by "add_header" directive 2 | 3 | Unfortunately, many people don't know how the inheritance of directives works. Most often this leads to misuse of the `add_header` directive while trying to add a new response header on the nested level. 4 | This feature is mentioned in Nginx [docs](http://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header): 5 | > There could be several `add_header` directives. These directives are inherited from the previous level if and only if there are no `add_header` directives defined on the current level. 6 | 7 | The logic is quite simple: if you set headers at one level (for example, in `server` section) and then at a lower level (let's say `location`) you set some other headers, then the first headers will be discarded. 8 | 9 | It's easy to check: 10 | - Configuration: 11 | ```nginx 12 | server { 13 | listen 80; 14 | add_header X-Frame-Options "DENY" always; 15 | location / { 16 | return 200 "index"; 17 | } 18 | 19 | location /new-headers { 20 | # Add special cache control 21 | add_header Cache-Control "no-cache, no-store, max-age=0, must-revalidate" always; 22 | add_header Pragma "no-cache" always; 23 | 24 | return 200 "new-headers"; 25 | } 26 | } 27 | ``` 28 | - Request to location `/` (`X-Frame-Options` header is in server response): 29 | ```http 30 | GET / HTTP/1.0 31 | 32 | HTTP/1.1 200 OK 33 | Server: nginx/1.10.2 34 | Date: Mon, 09 Jan 2017 19:28:33 GMT 35 | Content-Type: application/octet-stream 36 | Content-Length: 5 37 | Connection: close 38 | X-Frame-Options: DENY 39 | 40 | index 41 | ``` 42 | - Request to location `/new-headers` (headers `Cache-Control` and `Pragma` are present, but there's no `X-Frame-Options`): 43 | ```http 44 | GET /new-headers HTTP/1.0 45 | 46 | 47 | HTTP/1.1 200 OK 48 | Server: nginx/1.10.2 49 | Date: Mon, 09 Jan 2017 19:29:46 GMT 50 | Content-Type: application/octet-stream 51 | Content-Length: 11 52 | Connection: close 53 | Cache-Control: no-cache, no-store, max-age=0, must-revalidate 54 | Pragma: no-cache 55 | 56 | new-headers 57 | ``` 58 | 59 | ## What can I do? 60 | There are several ways to solve this problem: 61 | - duplicate important headers; 62 | - set all headers at one level (`server` section is a good choice) 63 | - use [ngx_headers_more](https://www.nginx.com/resources/wiki/modules/headers_more/) module. 64 | -------------------------------------------------------------------------------- /docs/en/plugins/aliastraversal.md: -------------------------------------------------------------------------------- 1 | # [alias_traversal] Path traversal via misconfigured alias 2 | 3 | The [alias](https://nginx.ru/en/docs/http/ngx_http_core_module.html#alias) directive is used to replace path of the specified location. 4 | For example, with the following configuration: 5 | ```nginx 6 | location /i/ { 7 | alias /data/w3/images/; 8 | } 9 | ``` 10 | On request of `/i/top.gif`, the file `/data/w3/images/top.gif` will be sent. 11 | 12 | But if the location doesn't end with directory separator (i.e. `/`): 13 | 14 | ```nginx 15 | location /i { 16 | alias /data/w3/images/; 17 | } 18 | ``` 19 | On request of `/i../app/config.py`, the file `/data/w3/app/config.py` will be sent. 20 | 21 | In other words, the incorrect configuration of `alias` could allow an attacker to read file stored outside the target folder. 22 | 23 | ## What can I do? 24 | 25 | It's pretty simple: 26 | - you must find all the `alias` directives; 27 | - make sure that the parent prefixed location ends with directory separator. 28 | - or if you want to map a single file make sure the location starts with a `=`, e.g. `=/i.gif` instead of `/i.gif`. 29 | -------------------------------------------------------------------------------- /docs/en/plugins/allow_without_deny.md: -------------------------------------------------------------------------------- 1 | # `allow` without `deny` 2 | 3 | When a configuration block contains `allow` directive with some IP address or subnet, it most likely should also contain `deny all;` directive (or it should be enforced somewhere else). 4 | **Otherwise, there's basically no access limitation.** 5 | 6 | ## Bad Example 7 | 8 | ```nginx 9 | location / { 10 | root /var/www/; 11 | allow 10.0.0.0/8; 12 | . . . 13 | } 14 | ``` 15 | 16 | ## Good Example 17 | 18 | ```nginx 19 | location / { 20 | root /var/www/; 21 | allow 10.0.0.0/8; 22 | deny all; 23 | . . . 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/en/plugins/error_log_off.md: -------------------------------------------------------------------------------- 1 | # [error_log_off] `error_log` set to `off` 2 | 3 | A common misconception is that using the directive `error_log off` disables error logging. 4 | Unlike the `access_log` directive, `error_log` does not accept an `off` parameter. 5 | If you add `error_log` off to your configuration, NGINX will create a log file named “off” in the default configuration directory (typically `/etc/nginx`). 6 | 7 | Disabling the error log is generally not advised, as it provides crucial information 8 | for troubleshooting NGINX issues. However, if disk space is extremely limited and 9 | there’s a risk that logging could fill up the available space, you might opt to 10 | disable error logging. To do so, add the following directive in the main configuration 11 | context: 12 | 13 | ```nginx 14 | error_log /dev/null emerg; 15 | ``` 16 | 17 | Keep in mind that this setting takes effect only after NGINX reads and validates 18 | the configuration file. Therefore, during startup or when reloading the configuration, 19 | NGINX may still log errors to the default error log location (usually `/var/log/nginx/error.log`) 20 | until validation is complete. To change the default log directory permanently, use the `--error-log-path` (or `-e`) option when starting NGINX. 21 | -------------------------------------------------------------------------------- /docs/en/plugins/hostspoofing.md: -------------------------------------------------------------------------------- 1 | # [host_spoofing] Request's Host header forgery 2 | 3 | Often, an application located behind Nginx needs a correct `Host` header for URL generation (redirects, resources, links in emails etc.). 4 | Spoofing of this header may lead to a variety of problems, from phishing to SSRF. 5 | 6 | > Notice: your application may also use the `X-Forwarded-Host` request header for this functionality. 7 | > In this case you have to ensure the header is set correctly; 8 | 9 | ## How can I find it? 10 | Most of the time it's a result of using `$http_host` variable instead of `$host`. 11 | 12 | And they are quite different: 13 | * `$host` - host in this order of precedence: host name from the request line, or host name from the “Host” request header field, or the server name matching a request; 14 | * `$http_host` - "Host" request header. 15 | 16 | Config sample: 17 | ```nginx 18 | location @app { 19 | proxy_set_header Host $http_host; 20 | # Other proxy params 21 | proxy_pass http://backend; 22 | } 23 | ``` 24 | 25 | ## What can I do? 26 | Luckily, all is quite obvious: 27 | * list all the correct server names in `server name` directive; 28 | * always use `$host` instead of `$http_host`. 29 | 30 | ## Additional info 31 | * [Host of Troubles Vulnerabilities](https://hostoftroubles.com/) 32 | * [Practical HTTP Host header attacks](http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html) 33 | -------------------------------------------------------------------------------- /docs/en/plugins/httpsplitting.md: -------------------------------------------------------------------------------- 1 | # [http_splitting] HTTP Splitting 2 | 3 | HTTP Splitting - attack that use improper input validation. It usually targets web application located behind Nginx (HTTP Request Splitting) or its users (HTTP Response Splitting). 4 | 5 | Vulnerability is created when an attacker can insert newline character `\n` or `\r` into request or into response, created by Nginx. 6 | 7 | ## How can I find it? 8 | You should always pay attention to: 9 | - variables that are used in directives, responsible for the request creation (for they may contain CRLF), e.g. `rewrite`, `return`, `add_header`, `proxy_set_header` or `proxy_pass`; 10 | - `$uri` and `$document_uri` variables, and in which directives they are used, because these variables contain decoded URL-encoded value; 11 | - variables, that are selected from an exclusive range, e.g. `(?P[^.]+)`. 12 | 13 | 14 | An example of configuration that contains variable, selected from an exclusive range: 15 | ```nginx 16 | server { 17 | listen 80 default; 18 | 19 | location ~ /v1/((?[^.]*)\.json)?$ { 20 | add_header X-Action $action; 21 | return 200 "OK"; 22 | } 23 | } 24 | ``` 25 | 26 | Exploitation: 27 | ```http 28 | GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0 29 | Host: localhost 30 | 31 | HTTP/1.1 200 OK 32 | Server: nginx/1.11.10 33 | Date: Mon, 13 Mar 2017 21:21:29 GMT 34 | Content-Type: application/octet-stream 35 | Content-Length: 2 36 | Connection: close 37 | X-Action: see below 38 | x-crlf-header:injected 39 | 40 | OK 41 | ``` 42 | 43 | As you can see, an attacker could add `x-crlf-header: injected` response header. This was possible because: 44 | - `add_header` doesn't encode or validate input value on suggestion that author knows about the consequences; 45 | - the path value is normalize before location processing; 46 | - `$action` value was given from a regexp with an exclusive range: `[^.]*`; 47 | - as the result, `$action` value is equal to `see below\r\nx-crlf-header:injected` and on its use the response header was added. 48 | 49 | ## What can I do? 50 | - try to use safe variables, e.g. `$request_uri` instead of `$uri`; 51 | - forbid the use of the new line symbol in the exclusive range by using `/some/(?[^/\s]+)` instead of `/some/(?[^/]+` 52 | - it could be a good idea to validate `$uri` (only if you're sure you know what are you getting into). 53 | -------------------------------------------------------------------------------- /docs/en/plugins/if_is_evil.md: -------------------------------------------------------------------------------- 1 | # If is Evil... when used in location context 2 | 3 | ## Introduction 4 | 5 | Directive [`if`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#if) has problems **when used in location context**, 6 | in some cases it doesn't do what you expect but something completely different instead. In some cases it even segfaults. It's generally a good idea to avoid it if possible. 7 | 8 | The only 100% safe things which may be done inside if in a location context are: 9 | 10 | * [`return ...;`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) 11 | * [`rewrite ... last;`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#rewrite) 12 | 13 | Anything else may cause unpredictable behavior, including potential SIGSEGV. 14 | 15 | It is important to note that the behavior of if is not inconsistent, given two identical requests it will not randomly fail on one and work on the other, with proper testing and understanding ifs _can_ be used. The advice to use other directives where available still very much applies, though. 16 | 17 | There are cases where you cannot avoid using an if, for example, if you need to test a variable which has no equivalent directive. 18 | 19 | ```nginx 20 | if ($request_method = POST ) { 21 | return 405; 22 | } 23 | if ($args ~ post=140){ 24 | rewrite ^ http://example.com/ permanent; 25 | } 26 | ``` 27 | 28 | ## What to do instead 29 | 30 | Use the "return ..." or "rewrite ... last" if it suits your needs. 31 | You can allocate additional locations and `map` if you want to set variables based on conditions. 32 | 33 | In some cases, it's also possible to move `if`s to server level (where it's safe as only other rewrite module directives are allowed within it). 34 | 35 | E.g., the following may be used to safely change location which will be used to process request: 36 | 37 | ```nginx 38 | location / { 39 | error_page 418 = @other; 40 | recursive_error_pages on; 41 | 42 | if ($something) { 43 | return 418; 44 | } 45 | 46 | # some configuration 47 | ... 48 | } 49 | 50 | location @other { 51 | # some other configuration 52 | ... 53 | } 54 | ``` 55 | 56 | In some cases it may be good idea to use embedded scripting modules ([embedded perl](https://nginx.org/en/docs/http/ngx_http_perl_module.html), or [Lua module](https://nginx-extras.getpagespeed.com/lua-scripting/)) to do the scripting. 57 | 58 | ## Examples 59 | 60 | Here are some examples which explain why if is evil. Don't try this at home. You were warned. 61 | 62 | ```nginx 63 | # Here is collection of unexpectedly buggy configurations to show that 64 | # if inside location is evil. 65 | 66 | # only second header will be present in response 67 | # not really bug, just how it works 68 | 69 | location /only-one-if { 70 | set $true 1; 71 | 72 | if ($true) { 73 | add_header X-First 1; 74 | } 75 | 76 | if ($true) { 77 | add_header X-Second 2; 78 | } 79 | 80 | return 204; 81 | } 82 | 83 | # request will be sent to backend without uri changed 84 | # to '/' due to if 85 | 86 | location /proxy-pass-uri { 87 | proxy_pass http://127.0.0.1:8080/; 88 | 89 | set $true 1; 90 | 91 | if ($true) { 92 | # nothing 93 | } 94 | } 95 | 96 | # try_files wont work due to if 97 | 98 | location /if-try-files { 99 | try_files /file @fallback; 100 | 101 | set $true 1; 102 | 103 | if ($true) { 104 | # nothing 105 | } 106 | } 107 | 108 | # nginx will SIGSEGV 109 | 110 | location /crash { 111 | 112 | set $true 1; 113 | 114 | if ($true) { 115 | # fastcgi_pass here 116 | fastcgi_pass 127.0.0.1:9000; 117 | } 118 | 119 | if ($true) { 120 | # no handler here 121 | } 122 | } 123 | 124 | # alias with captures isn't correcly inherited into implicit nested 125 | # location created by if 126 | 127 | location ~* ^/if-and-alias/(?.*) { 128 | alias /tmp/$file; 129 | 130 | set $true 1; 131 | 132 | if ($true) { 133 | # nothing 134 | } 135 | } 136 | ``` 137 | 138 | In case you think you found an example which isn't listed here - it's a good idea to report it to the [NGINX development mailing list](http://mailman.nginx.org/mailman/listinfo/nginx-devel). 139 | 140 | ## Why this happens and still not fixed 141 | 142 | Directive "if" is part of rewrite module which evaluates instructions imperatively. On the other hand, NGINX configuration in general is declarative. At some point due to users demand an attempt was made to enable some non-rewrite directives inside "if", and this lead to situation we have now. It mostly works, but... see above. 143 | 144 | It looks like the only correct fix would be to disable non-rewrite directives inside if completely. It would break many configuration out there though, so wasn't done yet. 145 | 146 | ## If you still want to use if inside location context 147 | 148 | If you read all of the above and still want to use if: 149 | 150 | * Please make sure you actually do understand how it works. Some basic idea may be found e.g. [here](http://agentzh.blogspot.com/2011/03/how-nginx-location-if-works.html). 151 | * Do proper testing. 152 | 153 | You were warned. 154 | -------------------------------------------------------------------------------- /docs/en/plugins/origins.md: -------------------------------------------------------------------------------- 1 | # [origins] Problems with referrer/origin validation 2 | 3 | It's not unusual to use regex for `Referer` or `Origin` headers validation. 4 | It is often necessary for setting the `X-Frame-Options` header (ClickJacking protection) or Cross-Origin Resource Sharing. 5 | 6 | The most common errors with this configuration are: 7 | - regex errors; 8 | - allow third-party origins. 9 | 10 | > Notice: by default, Gixy doesn't check regexes for third-party origins matching. 11 | > You can pass a list of trusted domains by using the option `--origins-domains example.com,foo.bar` 12 | 13 | ## How can I find it? 14 | "Eazy"-breezy: 15 | - you have to find all the `if` directives that are in charge of `$http_origin` or `$http_referer` check; 16 | - make sure your regexes are a-ok. 17 | 18 | Misconfiguration example: 19 | ```nginx 20 | if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) { 21 | add_header 'Access-Control-Allow-Origin' "$http_origin"; 22 | add_header 'Access-Control-Allow-Credentials' 'true'; 23 | } 24 | ``` 25 | 26 | TODO(buglloc): cover typical regex-writing problems 27 | TODO(buglloc): Regex Ninja? 28 | 29 | ## What can I do? 30 | 31 | - fix your regex or toss it away :) 32 | - if you use regex validation for `Referer` request header, then, possibly (not 100%), you could use [ngx_http_referer_module](http://nginx.org/en/docs/http/ngx_http_referer_module.htmll); 33 | - sometimes it is much better to use the `map` directive without any regex at all. 34 | -------------------------------------------------------------------------------- /docs/en/plugins/resolver_external.md: -------------------------------------------------------------------------------- 1 | # [resolver_external] Using external DNS nameservers 2 | -------------------------------------------------------------------------------- /docs/en/plugins/ssrf.md: -------------------------------------------------------------------------------- 1 | # [ssrf] Server Side Request Forgery 2 | 3 | Server Side Request Forgery - attack that forces a server to perform arbitrary requests (from Nginx in our case). 4 | It's possible when an attacker controls the address of a proxied server (second argument of the `proxy_pass` directive). 5 | 6 | 7 | ## How can I find it? 8 | There are two types of errors that make a server vulnerable: 9 | - lack of the [internal](http://nginx.org/en/docs/http/ngx_http_core_module.html#internal) directive. It is used to point out a location that can be used for internal requests only; 10 | - unsafe internal redirection. 11 | 12 | ### Lack of the internal directive 13 | Classical misconfiguration, based on lack of the `internal` directive, that makes SSRF possible: 14 | ```nginx 15 | location ~ /proxy/(.*)/(.*)/(.*)$ { 16 | proxy_pass $1://$2/$3; 17 | } 18 | ``` 19 | 20 | An attacker has complete control over the proxied address, that makes sending requests on behalf of Nginx possible. 21 | 22 | ### Unsafe internal redirection 23 | Let's say you have internal location in your config and that location uses some request data as proxied server's address. 24 | 25 | E.g.: 26 | ```nginx 27 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 28 | internal; 29 | 30 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 31 | proxy_set_header Host $proxy_host; 32 | } 33 | ``` 34 | 35 | According to Nginx docs, internal requests are the following: 36 | > - requests are redirected by the **error_page**, index, random_index, and **try_files** directives; 37 | > - requests redirected by the “X-Accel-Redirect” response header field from an upstream server; 38 | > - sub-requests formed by the “include virtual” command of the `ngx_http_ssi_module` module and by the ngx_http_addition_module module directives; 39 | > - requests changed by the **rewrite** directive 40 | 41 | Accordingly, any unsafe rewrite allows an attacker to make an internal request and control a proxied server's address. 42 | 43 | Misconfiguration example: 44 | ```nginx 45 | rewrite ^/(.*)/some$ /$1/ last; 46 | 47 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 48 | internal; 49 | 50 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 51 | proxy_set_header Host $proxy_host; 52 | } 53 | ``` 54 | 55 | ## What can I do? 56 | There are several rules you better follow when writing such configurations: 57 | - use only "internal locations" for proxying; 58 | - if possible, forbid user data transmission; 59 | - protect proxied server's address: 60 | * if the quantity of proxied hosts is limited (when you have S3 or smth), you better hardcode them and choose them with `map` or do it some other way; 61 | * if you can't list all possible hosts to proxy, you should sign the address. 62 | -------------------------------------------------------------------------------- /docs/en/plugins/unanchored_regex.md: -------------------------------------------------------------------------------- 1 | # [unanchored_regex]: Regular expression without anchors 2 | 3 | In NGINX, when defining location with regular expression, it's recommended to anchor the regex at least to the beginning or end of the string. 4 | Otherwise, the regex will match any part of the string, which may lead to unexpected behavior or decreased performance. 5 | 6 | For example, the following location block will match any URL that contains `/v1/`: 7 | 8 | ```nginx 9 | location ~ /v1/ { 10 | # ... 11 | } 12 | ``` 13 | 14 | This will match: 15 | 16 | - `/v1/` 17 | - `/v1/foo` 18 | - `/foo/v1/bar` 19 | - `/foo/v1/` 20 | 21 | To match only URLs that start with `/v1/`, the regex should be anchored: 22 | 23 | ```nginx 24 | location ~ ^/v1/ { 25 | # ... 26 | } 27 | ``` 28 | 29 | This way, the regex will match only URLs that start with `/v1/`. 30 | 31 | For matching file extensions, e.g., PHP files, the regex should be anchored at the end of the string. 32 | 33 | Incorrect: 34 | 35 | ```nginx 36 | location ~ \.php { 37 | # ... 38 | } 39 | ``` 40 | 41 | It will match any URL that contains `.php`: `/foo.php`, `/foo.phpanything`, which is incorrect. 42 | 43 | Correct: 44 | 45 | ```nginx 46 | location ~ \.php$ { 47 | # ... 48 | } 49 | ``` 50 | 51 | This way, the regex will match only URLs that end with `.php`. 52 | -------------------------------------------------------------------------------- /docs/en/plugins/validreferers.md: -------------------------------------------------------------------------------- 1 | # [valid_referrers] none in valid_referrers 2 | Module [ngx_http_referer_module](http://nginx.org/en/docs/http/ngx_http_referer_module.html) allows to block the access to service for requests with wrong `Referer` value. 3 | It's often used for setting `X-Frame-Options` header (ClickJacking protection), but there may be other cases. 4 | 5 | Typical problems with this module's config: 6 | * use of `server_names` with bad server name (`server_name` directive); 7 | * too broad and/or bad regexes; 8 | * use of `none`. 9 | 10 | > Notice: at the moment, Gixy can only detect the use of `none` as a valid referer. 11 | 12 | ## Why `none` is bad? 13 | 14 | According to [docs](http://nginx.org/ru/docs/http/ngx_http_referer_module.html#valid_referers): 15 | > `none` - the “Referer” field is missing in the request header; 16 | 17 | Still, it's important to remember that any resource can make user's browser to make a request without a `Referer` request header. 18 | E.g.: 19 | - in case of redirect from HTTPS to HTTP; 20 | - by setting up the [Referrer Policy](https://www.w3.org/TR/referrer-policy/); 21 | - a request with opaque origin, `data:` scheme, for example. 22 | 23 | So, by using `none` as a valid referer, you nullify any attempts in refferer validation. 24 | -------------------------------------------------------------------------------- /docs/en/plugins/version_disclosure.md: -------------------------------------------------------------------------------- 1 | # [version_disclosure] Disclosure of version information 2 | 3 | ## Problem 4 | 5 | Nginx version disclosure. 6 | 7 | ## Description 8 | 9 | Nginx version disclosure is a security vulnerability that allows an attacker to obtain information about the version of Nginx running on the server. 10 | 11 | ## Recommendation 12 | 13 | Disable version disclosure by adding the following directive to your `nginx.conf`: 14 | 15 | ```nginx 16 | server_tokens off; 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/en/plugins/worker_rlimit_nofile_vs_connections.md: -------------------------------------------------------------------------------- 1 | ## [worker_rlimit_nofile_vs_connections] worker_rlimit_nofile must be at least twice `worker_connections` 2 | 3 | A frequent configuration error is not raising the file descriptor (FD) limit to at least double the `worker_connections` value. To resolve this, configure the `worker_rlimit_nofile directive` in the main configuration context, 4 | and ensure it is at least twice the value of `worker_connections`. 5 | 6 | Why are additional FDs necessary? 7 | 8 | * **Web Server Mode**: 9 | * FD is used for the client connection. 10 | * An additional FD is required for each file served, meaning at least two FDs per client—even more if the web page consists of multiple files. 11 | * **Proxy Server Mode**: 12 | * One FD for the connection to the client. 13 | * One FD for the connection to the upstream server. 14 | * Potentially a third FD for temporarily storing the upstream server’s response. 15 | * **Caching Server Mode**: 16 | * NGINX behaves like a web server when serving cached responses (using FDs similarly as above). 17 | * It acts like a proxy server when the cache is empty or the cached content has expired. 18 | 19 | By ensuring the FD limit is at least twice the number of `worker_connections`, you accommodate the minimum FD requirements across these different modes of operation. 20 | -------------------------------------------------------------------------------- /docs/gixy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/docs/gixy.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | GIXY 2 | ==== 3 | [![Mozilla Public License 2.0](https://img.shields.io/badge/license-MPLv2.0-brightgreen?style=flat-square)](https://github.com/dvershinin/gixy/blob/master/LICENSE) 4 | [![Python tests](https://github.com/dvershinin/gixy/actions/workflows/pythonpackage.yml/badge.svg)](https://github.com/dvershinin/gixy/actions/workflows/pythonpackage.yml) 5 | [![Your feedback is greatly appreciated](https://img.shields.io/maintenance/yes/2025.svg?style=flat-square)](https://github.com/dvershinin/gixy/issues/new) 6 | [![GitHub issues](https://img.shields.io/github/issues/dvershinin/gixy.svg?style=flat-square)](https://github.com/dvershinin/gixy/issues) 7 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/dvershinin/gixy.svg?style=flat-square)](https://github.com/dvershinin/gixy/pulls) 8 | 9 | # Overview 10 | Gixy logo 11 | 12 | Gixy is a tool to analyze Nginx configuration. 13 | The main goal of Gixy is to prevent security misconfiguration and automate flaw detection. 14 | 15 | Currently supported Python versions are 3.6 through 3.13. 16 | 17 | Disclaimer: Gixy is well tested only on GNU/Linux, other OSs may have some issues. 18 | 19 | # What it can do 20 | Right now Gixy can find: 21 | 22 | * [[ssrf] Server Side Request Forgery](en/plugins/ssrf.md) 23 | * [[http_splitting] HTTP Splitting](en/plugins/httpsplitting.md) 24 | * [[origins] Problems with referrer/origin validation](en/plugins/origins.md) 25 | * [[add_header_redefinition] Redefining of response headers by "add_header" directive](en/plugins/addheaderredefinition.md) 26 | * [[host_spoofing] Request's Host header forgery](en/plugins/hostspoofing.md) 27 | * [[valid_referrers] none in valid_referers](en/plugins/validreferers.md) 28 | * [[add_header_multiline] Multiline response headers](en/plugins/addheadermultiline.md) 29 | * [[alias_traversal] Path traversal via misconfigured alias](en/plugins/aliastraversal.md) 30 | * [[if_is_evil] If is evil when used in location context](en/plugins/if_is_evil.md) 31 | * [[allow_without_deny] Allow specified without deny](en/plugins/allow_without_deny.md) 32 | * [[add_header_content_type] Setting Content-Type via add_header](en/plugins/add_header_content_type.md) 33 | * [[resolver_external] Using external DNS nameservers](https://blog.zorinaq.com/nginx-resolver-vulns/) 34 | * [[version_disclosure] Using insecure values for server_tokens](en/plugins/version_disclosure.md) 35 | * [[proxy_pass_normalized] Using proxy_pass with a pathname will normalize and decode the requested path when proxying](https://joshua.hu/proxy-pass-nginx-decoding-normalizing-url-path-dangerous#nginx-proxy_pass) 36 | * [[regex_redos] Regular expressions may result in easy denial-of-service (ReDoS) attacks](https://joshua.hu/regex-redos-recheck-nginx-gixy) 37 | 38 | You can find things that Gixy is learning to detect at [Issues labeled with "new plugin"](https://github.com/dvershinin/gixy/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+plugin%22) 39 | 40 | # Installation 41 | 42 | ## CentOS/RHEL and other RPM-based systems 43 | 44 | ```bash 45 | yum -y install https://extras.getpagespeed.com/release-latest.rpm 46 | yum -y install gixy 47 | ``` 48 | ### Other systems 49 | 50 | Gixy is distributed on [PyPI](https://pypi.python.org/pypi/gixy-ng). The best way to install it is with pip: 51 | 52 | ```bash 53 | pip install gixy-ng 54 | ``` 55 | 56 | Run Gixy and check results: 57 | ```bash 58 | gixy 59 | ``` 60 | 61 | # Usage 62 | 63 | By default, Gixy will try to analyze Nginx configuration placed in `/etc/nginx/nginx.conf`. 64 | 65 | But you can always specify needed path: 66 | ``` 67 | $ gixy /etc/nginx/nginx.conf 68 | 69 | ==================== Results =================== 70 | 71 | Problem: [http_splitting] Possible HTTP-Splitting vulnerability. 72 | Description: Using variables that can contain "\n" may lead to http injection. 73 | Additional info: https://github.com/dvershinin/gixy/blob/master/docs/ru/plugins/httpsplitting.md 74 | Reason: At least variable "$action" can contain "\n" 75 | Pseudo config: 76 | include /etc/nginx/sites/default.conf; 77 | 78 | server { 79 | 80 | location ~ /v1/((?[^.]*)\.json)?$ { 81 | add_header X-Action $action; 82 | } 83 | } 84 | 85 | 86 | ==================== Summary =================== 87 | Total issues: 88 | Unspecified: 0 89 | Low: 0 90 | Medium: 0 91 | High: 1 92 | ``` 93 | 94 | Or skip some tests: 95 | ``` 96 | $ gixy --skips http_splitting /etc/nginx/nginx.conf 97 | 98 | ==================== Results =================== 99 | No issues found. 100 | 101 | ==================== Summary =================== 102 | Total issues: 103 | Unspecified: 0 104 | Low: 0 105 | Medium: 0 106 | High: 0 107 | ``` 108 | 109 | Or something else, you can find all other `gixy` arguments with the help command: `gixy --help` 110 | 111 | You can also make `gixy` use pipes (stdin), like so: 112 | 113 | ```bash 114 | echo "resolver 1.1.1.1;" | gixy - 115 | ``` 116 | 117 | ## Docker usage 118 | 119 | Gixy is available as a Docker image [from the Docker hub](https://hub.docker.com/r/getpagespeed/gixy/). To 120 | use it, mount the configuration that you want to analyse as a volume and provide the path to the 121 | configuration file when running the Gixy image. 122 | ``` 123 | $ docker run --rm -v `pwd`/nginx.conf:/etc/nginx/conf/nginx.conf getpagespeed/gixy /etc/nginx/conf/nginx.conf 124 | ``` 125 | 126 | If you have an image that already contains your nginx configuration, you can share the configuration 127 | with the Gixy container as a volume. 128 | ``` 129 | $ docker run --rm --name nginx -d -v /etc/nginx 130 | nginx:alpinef68f2833e986ae69c0a5375f9980dc7a70684a6c233a9535c2a837189f14e905 131 | 132 | $ docker run --rm --volumes-from nginx dvershinin/gixy /etc/nginx/nginx.conf 133 | 134 | ==================== Results =================== 135 | No issues found. 136 | 137 | ==================== Summary =================== 138 | Total issues: 139 | Unspecified: 0 140 | Low: 0 141 | Medium: 0 142 | High: 0 143 | 144 | ``` 145 | 146 | # Contributing 147 | Contributions to Gixy are always welcome! You can help us in different ways: 148 | * Open an issue with suggestions for improvements and errors you're facing; 149 | * Fork this repository and submit a pull request; 150 | * Improve the documentation. 151 | 152 | Code guidelines: 153 | * Python code style should follow [pep8](https://www.python.org/dev/peps/pep-0008/) standards whenever possible; 154 | * Pull requests with new plugins must have unit tests for them. 155 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/docs/logo.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | markdown-include 4 | pymdown-extensions 5 | -------------------------------------------------------------------------------- /docs/ru/plugins/addheadermultiline.md: -------------------------------------------------------------------------------- 1 | # [add_header_multiline] Многострочные заголовки ответа 2 | 3 | Многострочных заголовков ответа стоит избегать по нескольким причинам: 4 | * они признаны устаревшими (см. [RFC 7230](https://tools.ietf.org/html/rfc7230#section-3.2.4)); 5 | * они никогда не поддерживались многими HTTP-клиентами и браузерами. Например, IE/Edge/Nginx. 6 | 7 | ## Как самостоятельно обнаружить? 8 | Пример плохой конфигурации: 9 | ```nginx 10 | # http://nginx.org/ru/docs/http/ngx_http_headers_module.html#add_header 11 | add_header Content-Security-Policy " 12 | default-src: 'none'; 13 | script-src data: https://yastatic.net; 14 | style-src data: https://yastatic.net; 15 | img-src data: https://yastatic.net; 16 | font-src data: https://yastatic.net;"; 17 | 18 | # https://www.nginx.com/resources/wiki/modules/headers_more/ 19 | more_set_headers -t 'text/html text/plain' 20 | 'X-Foo: Bar 21 | multiline'; 22 | ``` 23 | 24 | ## Что делать? 25 | Единственный выход - отказ от многострочных заголовок ответа. 26 | -------------------------------------------------------------------------------- /docs/ru/plugins/addheaderredefinition.md: -------------------------------------------------------------------------------- 1 | # [add_header_redefinition] Переопределение "вышестоящих" заголовков ответа директивой "add_header" 2 | 3 | К сожалению, многие считают что с помощью директивы `add_header` можно произвольно доопределять заголовки ответа. 4 | Это не так, о чем сказано в [документации](http://nginx.org/ru/docs/http/ngx_http_headers_module.html#add_header) к Nginx: 5 | > Директив `add_header` может быть несколько. Директивы наследуются с предыдущего уровня при условии, что на данном уровне не описаны свои директивы `add_header`. 6 | 7 | К слову, так работает наследование большинства директив в nginx'е. Если вы задаёте что-то на каком-то уровне конфигурации (например, в локейшене), то наследования с предыдущих уровней (например, с http секции) - не будет. 8 | 9 | В этом довольно легко убедится: 10 | - Конфигурация: 11 | ```nginx 12 | server { 13 | listen 80; 14 | add_header X-Frame-Options "DENY" always; 15 | location / { 16 | return 200 "index"; 17 | } 18 | 19 | location /new-headers { 20 | # Add special cache control 21 | add_header Cache-Control "no-cache, no-store, max-age=0, must-revalidate" always; 22 | add_header Pragma "no-cache" always; 23 | 24 | return 200 "new-headers"; 25 | } 26 | } 27 | ``` 28 | - Запрос к локейшену `/` (заголовок `X-Frame-Options` есть в ответе сервера): 29 | ```http 30 | GET / HTTP/1.0 31 | 32 | HTTP/1.1 200 OK 33 | Server: nginx/1.10.2 34 | Date: Mon, 09 Jan 2017 19:28:33 GMT 35 | Content-Type: application/octet-stream 36 | Content-Length: 5 37 | Connection: close 38 | X-Frame-Options: DENY 39 | 40 | index 41 | ``` 42 | - Запрос к локейшену `/new-headers` (есть заголовки `Cache-Control` и `Pragma`, но нет `X-Frame-Options`): 43 | ```http 44 | GET /new-headers HTTP/1.0 45 | 46 | 47 | HTTP/1.1 200 OK 48 | Server: nginx/1.10.2 49 | Date: Mon, 09 Jan 2017 19:29:46 GMT 50 | Content-Type: application/octet-stream 51 | Content-Length: 11 52 | Connection: close 53 | Cache-Control: no-cache, no-store, max-age=0, must-revalidate 54 | Pragma: no-cache 55 | 56 | new-headers 57 | ``` 58 | 59 | ## Что делать? 60 | Существует несколько способов решить эту проблему: 61 | - продублировать важные заголовки; 62 | - устанавливать заголовки на одном уровне, например, в серверной секции; 63 | - использовать модуль [ngx_headers_more](https://www.nginx.com/resources/wiki/modules/headers_more/). 64 | 65 | Каждый из способов имеет свои преимущества и недостатки, какой предпочесть зависит от ваших потребностей. -------------------------------------------------------------------------------- /docs/ru/plugins/aliastraversal.md: -------------------------------------------------------------------------------- 1 | # [alias_traversal] Path traversal при использовании alias 2 | 3 | Директива [alias](https://nginx.ru/ru/docs/http/ngx_http_core_module.html#alias) используется для замены пути указанного локейшена. 4 | К примеру, для конфигурации: 5 | ```nginx 6 | location /i/ { 7 | alias /data/w3/images/; 8 | } 9 | ``` 10 | На запрос `/i/top.gif` будет отдан файл `/data/w3/images/top.gif`. 11 | 12 | Однако, если локейшен не оканчивается разделителем директорий (`/`): 13 | ```nginx 14 | location /i { 15 | alias /data/w3/images/; 16 | } 17 | ``` 18 | То на запрос `/i../app/config.py` будет отдан файл `/data/w3/app/config.py`. 19 | 20 | Иными словами, не корректная конфигурация `alias` может позволить злоумышленнику прочесть файл за пределами целевой директории. 21 | 22 | ## Что делать? 23 | Все довольно просто: 24 | - необходимо найти все директивы `alias`; 25 | - убедится что вышестоящий префиксный локейшен оканчивается на `/`. 26 | -------------------------------------------------------------------------------- /docs/ru/plugins/hostspoofing.md: -------------------------------------------------------------------------------- 1 | # [host_spoofing] Подделка заголовка запроса Host 2 | 3 | Зачастую, приложению, стоящему за Nginx, необходимо передать корректный заголовок `Host` для корректной генерации различных URL-адресов (редиректы, ресурсы, ссылки в письмах и т.д.). 4 | Возможность его подмены злоумышленником может повлечь множество проблем от фишинговых атак до SSRF, поэтому следует избегать таких ситуаций. 5 | 6 | > Возможно, ваше приложение так же ориентируется на заголовок запроса `X-Forwarded-Host`. 7 | > В этом случае вам необходимо самостоятельно позаботится о его корректной установке при проксировании. 8 | 9 | ## Как самостоятельно обнаружить? 10 | Чаще всего эта проблема возникает в результате использования переменной `$http_host` вместо `$host`. 11 | 12 | Несмотря на их схожесть, они сильно отличаются: 13 | * `$host` - хост в порядке приоритета: имя хоста из строки запроса, или имя хоста из заголовка `Host` заголовка запроса, или имя сервера, соответствующего запросу; 14 | * `$http_host` - заголовок запроса "Host". 15 | 16 | Пример такой конфигурации: 17 | ```nginx 18 | location @app { 19 | proxy_set_header Host $http_host; 20 | # Other proxy params 21 | proxy_pass http://backend; 22 | } 23 | ``` 24 | 25 | ## Что делать? 26 | К счастью, все довольно очевидно: 27 | * перечислить корректные имена сервера в директиве `server_name`; 28 | * всегда использовать переменную `$host`, вместо `$http_host`. 29 | 30 | ## Дополнительная информация 31 | * [Host of Troubles Vulnerabilities](https://hostoftroubles.com/) 32 | * [Practical HTTP Host header attacks](http://www.skeletonscribe.net/2013/05/practical-http-host-header-attacks.html) 33 | -------------------------------------------------------------------------------- /docs/ru/plugins/httpsplitting.md: -------------------------------------------------------------------------------- 1 | # [http_splitting] HTTP Splitting 2 | 3 | HTTP Splitting - уязвимость, возникающая из-за неправильной обработки входных данных. 4 | Зачастую может быть для атак на приложение стоящее за Nginx (HTTP Request Splitting) или на клиентов приложения (HTTP Response Splitting). 5 | 6 | Уязвимость возникает в случае, когда атакующий может внедрить символ перевода строки `\n` или `\r` в запрос или ответ формируемый Nginx. 7 | 8 | ## Как самостоятельно обнаружить? 9 | При анализе конфигурации всегда стоит обращать внимание на: 10 | - какие переменные используются в директивах, отвечающих за формирование запросов (могут ли они содержать CRLF), например: `rewrite`, `return`, `add_header`, `proxy_set_header` или `proxy_pass`; 11 | - используются ли переменные `$uri` и `$document_uri` и если да, то в каких директивах, так как они гарантированно содержат урлдекодированное значение; 12 | - переменные, выделенные из групп с исключающим диапазоном: `(?P[^.]+)`. 13 | 14 | Пример плохой конфигурации с переменной, полученной из группы с исключающим диапазоном: 15 | ```nginx 16 | server { 17 | listen 80 default; 18 | 19 | location ~ /v1/((?[^.]*)\.json)?$ { 20 | add_header X-Action $action; 21 | return 200 "OK"; 22 | } 23 | } 24 | ``` 25 | 26 | Пример эксплуатации данной конфигурации: 27 | ```http 28 | GET /v1/see%20below%0d%0ax-crlf-header:injected.json HTTP/1.0 29 | Host: localhost 30 | 31 | HTTP/1.1 200 OK 32 | Server: nginx/1.11.10 33 | Date: Mon, 13 Mar 2017 21:21:29 GMT 34 | Content-Type: application/octet-stream 35 | Content-Length: 2 36 | Connection: close 37 | X-Action: see below 38 | x-crlf-header:injected 39 | 40 | OK 41 | ``` 42 | 43 | Из примера видно, что злоумышленник смог добавить заголовок ответа `x-crlf-header: injected`. Это случилось благодаря стечению нескольких обстоятельств: 44 | - `add_header` не кодирует/проверяет переданные ему значения, считая, что автор знает о последствиях; 45 | - значение пути нормализуется перед обработкой локейшена; 46 | - переменная `$action` была выделена из группы регулярного выражения с исключающим диапазоном: `[^.]*`; 47 | - таким образом, значение переменной `$action` равно `see below\r\nx-crlf-header:injected` и при её использовании в формировании ответа добавился заголовок. 48 | 49 | ## Что делать? 50 | - старайтесь использовать более безопасные переменные, например, `$request_uri` вместо `$uri`; 51 | - запретите перевод строки в исключающем диапазоне, например, `/some/(?[^/\s]+)` вместо `/some/(?[^/]+`; 52 | - возможно, хорошей идеей будет добавить валидацию `$uri` (только если вы знаете, что делаете). 53 | -------------------------------------------------------------------------------- /docs/ru/plugins/origins.md: -------------------------------------------------------------------------------- 1 | # [origins] Проблемы валидации referrer/origin 2 | 3 | Нередко валидация заголовка запроса `Referer` или `Origin` делается при помощи регулярного выражения. 4 | Зачастую, это необходимо для условного выставления заголовка `X-Frame-Options` (защита от ClickJacking) или реализации Cross-Origin Resource Sharing. 5 | 6 | Наиболее распространено два класса ошибок конфигурации, которые приводят к этой проблеме: 7 | - ошибки в составлении регулярного выражения; 8 | - разрешение не доверенных third-party доменов. 9 | 10 | > По умолчанию Gixy не проверяет регулярные выражения на предмет матчинга third-party доменов, так как не знает кому можно верить. 11 | Передать список доверенных доменом можно при помощи опции `--origins-domains example.com,foo.bar` 12 | 13 | ## Как самостоятельно обнаружить? 14 | Все довольно "просто": 15 | - необходимо найти все директивы `if`, которые делают проверку переменной `$http_origin` или `$http_referer`; 16 | - убедится что в регулярном выражении нет проблем. 17 | 18 | Пример плохой конфигурации: 19 | ```nginx 20 | if ($http_origin ~* ((^https://www\.yandex\.ru)|(^https://ya\.ru)$)) { 21 | add_header 'Access-Control-Allow-Origin' "$http_origin"; 22 | add_header 'Access-Control-Allow-Credentials' 'true'; 23 | } 24 | ``` 25 | 26 | TODO(buglloc): описать типичные проблемы при составлении регулярных выражений 27 | TODO(buglloc): Regex Ninja? 28 | 29 | ## Что делать? 30 | - исправить регулярное выражение или отказаться от него вовсе 31 | - если вы проверяете заголовок запроса `Referer` то, возможно (имеются противопоказания), лучшим решением было бы воспользоваться модулем [ngx_http_referer_module](http://nginx.org/ru/docs/http/ngx_http_referer_module.html); 32 | - если вы проверяете заголовков запроса `Origin` то, зачастую, лучше использовать `map` и отказаться от регулярных выражений. 33 | -------------------------------------------------------------------------------- /docs/ru/plugins/ssrf.md: -------------------------------------------------------------------------------- 1 | # [ssrf] Server Side Request Forgery 2 | 3 | Server Side Request Forgery - уязвимость, позволяющая выполнять различного рода запросы от имени веб-приложения (в нашем случае от имени Nginx). 4 | Возникает, когда атакующий может контролировать адрес проксируемого сервера (второй аргумент директивы `proxy_pass`). 5 | 6 | 7 | ## Как самостоятельно обнаружить? 8 | Наиболее распространено два класса ошибок конфигурации, которые приводят к этой проблеме: 9 | - отсутствие директивы [internal](http://nginx.org/ru/docs/http/ngx_http_core_module.html#internal). Её смысл заключается в указании того, что определенный location может использоваться только для внутренних запросов; 10 | - небезопасное внутреннее перенаправление. 11 | 12 | ### Отсутствие директивы internal 13 | Классический пример уязвимости типа SSRF ввиду отсутствия директивы `internal` выглядит следующим образом: 14 | ```nginx 15 | location ~ /proxy/(.*)/(.*)/(.*)$ { 16 | proxy_pass $1://$2/$3; 17 | } 18 | ``` 19 | Злоумышленник, полностью контролируя адрес проксируемого сервера, может выполнять произвольные запросы от имени Nginx. 20 | 21 | ### Небезопасное внутреннее перенаправление 22 | Подразумевается, что в вашей конфигурации есть internal location, которые использует какие-либо данные из запроса в качестве адреса проксируемого сервера. 23 | 24 | Например: 25 | ```nginx 26 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 27 | internal; 28 | 29 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 30 | proxy_set_header Host $proxy_host; 31 | } 32 | ``` 33 | 34 | Согласно документации Nginx внутренними запросами являются: 35 | > - запросы, перенаправленные директивами **error_page**, index, random_index и **try_files**; 36 | > - запросы, перенаправленные с помощью поля “X-Accel-Redirect” заголовка ответа вышестоящего сервера; 37 | > - подзапросы, формируемые командой “include virtual” модуля ngx_http_ssi_module и директивами модуля ngx_http_addition_module; 38 | > - запросы, изменённые директивой **rewrite**.]> 39 | 40 | Соответственно, любой "неосторожный" реврайт позволит злоумышленнику сделать внутренний запрос и контролировать адрес проксируемого сервера. 41 | 42 | Пример плохой конфигурации: 43 | ```nginx 44 | rewrite ^/(.*)/some$ /$1/ last; 45 | 46 | location ~* ^/internal-proxy/(?https?)/(?.*?)/(?.*)$ { 47 | internal; 48 | 49 | proxy_pass $proxy_proto://$proxy_host/$proxy_path ; 50 | proxy_set_header Host $proxy_host; 51 | } 52 | ``` 53 | 54 | ## Что делать? 55 | Есть несколько правил, которых стоит придерживаться в подобного рода конфигурациях: 56 | - использовать только internal location для проксирования; 57 | - по возможности запретить передачу пользовательских данных; 58 | - обезопасить адрес проксируемого сервера: 59 | * если количество проксируемых хостов ограниченно (например, у вас S3), то лучше их захардкодить и выбирать при помощи `map` или иным удобным для вас образом; 60 | * если по какой-то причине нет возможности перечислить все возможные хосты для проксирования, его стоит подписать. 61 | -------------------------------------------------------------------------------- /docs/ru/plugins/validreferers.md: -------------------------------------------------------------------------------- 1 | # [valid_referrers] none in valid_referers 2 | 3 | Модуль [ngx_http_referer_module](http://nginx.org/ru/docs/http/ngx_http_referer_module.html) позволяет блокировать доступ к сервису для запросов с неверными значениями заголовка запроса `Referer`. 4 | Зачастую используется для условного выставления заголовка `X-Frame-Options` (защита от ClickJacking), но могут быть и иные случаи. 5 | 6 | Типичные проблемы при конфигурировании этого модуля: 7 | * использование `server_names` при не корректном имени сервера (директива `server_name`); 8 | * слишком общие и/или не корректные регулярные выражения; 9 | * использование `none`. 10 | 11 | > На текущий момент, Gixy умеет определять только использование `none` в качестве валидного реферера. 12 | 13 | ## Чем плох none? 14 | Согласно [документации](http://nginx.org/ru/docs/http/ngx_http_referer_module.html#valid_referers): 15 | > `none` - поле “Referer” в заголовке запроса отсутствует; 16 | 17 | Однако, важно помнить, что любой ресурс может заставить браузер пользователя выполнить запрос без заголовка запроса `Referer`, к примеру: 18 | - в случае редиректа с HTTPS на HTTP; 19 | - указав соответствующую [Referrer Policy](https://www.w3.org/TR/referrer-policy/); 20 | - обращение с opaque origin, например, используя схему `data:`. 21 | 22 | Таким образом, используя `none` в качестве валидного реферера вы сводите на нет любые попытки валидации реферера. 23 | -------------------------------------------------------------------------------- /gixy/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from gixy.core import severity 4 | 5 | version = "0.2.7" 6 | -------------------------------------------------------------------------------- /gixy/__main__.py: -------------------------------------------------------------------------------- 1 | """Used to run the CLI package as a module.""" 2 | 3 | from gixy.cli import main 4 | 5 | main.main() 6 | -------------------------------------------------------------------------------- /gixy/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/cli/__init__.py -------------------------------------------------------------------------------- /gixy/cli/__main__.py: -------------------------------------------------------------------------------- 1 | """Entry point for the CLI.""" 2 | 3 | from gixy.cli.main import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /gixy/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/core/__init__.py -------------------------------------------------------------------------------- /gixy/core/config.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | 3 | 4 | class Config(object): 5 | def __init__(self, 6 | plugins=None, 7 | skips=None, 8 | severity=gixy.severity.UNSPECIFIED, 9 | output_format=None, 10 | output_file=None, 11 | allow_includes=True): 12 | self.severity = severity 13 | self.output_format = output_format 14 | self.output_file = output_file 15 | self.plugins = plugins 16 | self.skips = skips 17 | self.allow_includes = allow_includes 18 | self.plugins_options = {} 19 | 20 | def set_for(self, name, options): 21 | self.plugins_options[name] = options 22 | 23 | def get_for(self, name): 24 | if self.has_for(name): 25 | return self.plugins_options[name] 26 | return {} 27 | 28 | def has_for(self, name): 29 | return name in self.plugins_options 30 | -------------------------------------------------------------------------------- /gixy/core/context.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import copy 3 | 4 | from gixy.core.utils import is_indexed_name 5 | 6 | LOG = logging.getLogger(__name__) 7 | 8 | CONTEXTS = [] 9 | 10 | 11 | def get_context(): 12 | return CONTEXTS[-1] 13 | 14 | 15 | def purge_context(): 16 | del CONTEXTS[:] 17 | 18 | 19 | def push_context(block): 20 | if len(CONTEXTS): 21 | context = copy.deepcopy(get_context()) 22 | else: 23 | context = Context() 24 | context.set_block(block) 25 | CONTEXTS.append(context) 26 | return context 27 | 28 | 29 | def pop_context(): 30 | return CONTEXTS.pop() 31 | 32 | 33 | class Context(object): 34 | def __init__(self): 35 | self.block = None 36 | self.variables = { 37 | 'index': {}, 38 | 'name': {} 39 | } 40 | 41 | def set_block(self, directive): 42 | self.block = directive 43 | return self 44 | 45 | def clear_index_vars(self): 46 | self.variables['index'] = {} 47 | return self 48 | 49 | def add_var(self, name, var): 50 | if is_indexed_name(name): 51 | var_type = 'index' 52 | name = int(name) 53 | else: 54 | var_type = 'name' 55 | 56 | self.variables[var_type][name] = var 57 | return self 58 | 59 | def get_var(self, name): 60 | if is_indexed_name(name): 61 | var_type = 'index' 62 | name = int(name) 63 | else: 64 | var_type = 'name' 65 | 66 | result = None 67 | try: 68 | result = self.variables[var_type][name] 69 | except KeyError: 70 | if var_type == 'name': 71 | # Only named variables can be builtins 72 | import gixy.core.builtin_variables as builtins 73 | 74 | if builtins.is_builtin(name): 75 | result = builtins.builtin_var(name) 76 | 77 | if not result: 78 | LOG.info("Can't find variable '{0}'".format(name)) 79 | return result 80 | 81 | def __deepcopy__(self, memo): 82 | cls = self.__class__ 83 | result = cls.__new__(cls) 84 | memo[id(self)] = result 85 | result.block = copy.copy(self.block) 86 | result.variables = { 87 | 'index': copy.copy(self.variables['index']), 88 | 'name': copy.copy(self.variables['name']) 89 | } 90 | return result 91 | -------------------------------------------------------------------------------- /gixy/core/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidConfiguration(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /gixy/core/issue.py: -------------------------------------------------------------------------------- 1 | class Issue(object): 2 | def __init__(self, plugin, summary=None, description=None, 3 | severity=None, reason=None, help_url=None, directives=None): 4 | self.plugin = plugin 5 | self.summary = summary 6 | self.description = description 7 | self.severity = severity 8 | self.reason = reason 9 | self.help_url = help_url 10 | if not directives: 11 | self.directives = [] 12 | elif not hasattr(directives, '__iter__'): 13 | self.directives = [directives] 14 | else: 15 | self.directives = directives 16 | -------------------------------------------------------------------------------- /gixy/core/manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import gixy 5 | from gixy.core.plugins_manager import PluginsManager 6 | from gixy.core.context import get_context, pop_context, push_context, purge_context 7 | from gixy.parser.nginx_parser import NginxParser 8 | from gixy.core.config import Config 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class Manager(object): 14 | def __init__(self, config=None): 15 | self.root = None 16 | self.config = config or Config() 17 | self.auditor = PluginsManager(config=self.config) 18 | 19 | def audit(self, file_path, file_data, is_stdin=False): 20 | LOG.debug("Audit config file: {fname}".format(fname=file_path)) 21 | parser = NginxParser( 22 | cwd=os.path.dirname(file_path) if not is_stdin else '', 23 | allow_includes=self.config.allow_includes) 24 | self.root = parser.parse(content=file_data.read(), path_info=file_path) 25 | 26 | push_context(self.root) 27 | self._audit_recursive(self.root.children) 28 | 29 | @property 30 | def results(self): 31 | for plugin in self.auditor.plugins: 32 | if plugin.issues: 33 | yield plugin 34 | 35 | @property 36 | def stats(self): 37 | stats = dict.fromkeys(gixy.severity.ALL, 0) 38 | for plugin in self.auditor.plugins: 39 | base_severity = plugin.severity 40 | for issue in plugin.issues: 41 | # TODO(buglloc): encapsulate into Issue class? 42 | severity = issue.severity if issue.severity else base_severity 43 | stats[severity] += 1 44 | return stats 45 | 46 | def _audit_recursive(self, tree): 47 | for directive in tree: 48 | self._update_variables(directive) 49 | self.auditor.audit(directive) 50 | if directive.is_block: 51 | if directive.self_context: 52 | push_context(directive) 53 | self._audit_recursive(directive.children) 54 | if directive.self_context: 55 | pop_context() 56 | 57 | def _update_variables(self, directive): 58 | # TODO(buglloc): finish him! 59 | if not directive.provide_variables: 60 | return 61 | 62 | context = get_context() 63 | for var in directive.variables: 64 | if var.name == 0: 65 | # All regexps must clean indexed variables 66 | context.clear_index_vars() 67 | context.add_var(var.name, var) 68 | 69 | def __enter__(self): 70 | return self 71 | 72 | def __exit__(self, exc_type, exc_val, exc_tb): 73 | purge_context() 74 | -------------------------------------------------------------------------------- /gixy/core/plugins_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | 6 | 7 | class PluginsManager(object): 8 | def __init__(self, config=None): 9 | self.imported = False 10 | self.config = config 11 | self._plugins = [] 12 | 13 | def import_plugins(self): 14 | if self.imported: 15 | return 16 | 17 | files_list = os.listdir( 18 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "plugins") 19 | ) 20 | for plugin_file in files_list: 21 | if not plugin_file.endswith(".py") or plugin_file.startswith("_"): 22 | continue 23 | __import__( 24 | "gixy.plugins." + os.path.splitext(plugin_file)[0], None, None, [""] 25 | ) 26 | 27 | self.imported = True 28 | 29 | def init_plugins(self): 30 | self.import_plugins() 31 | 32 | exclude = self.config.skips if self.config else None 33 | include = self.config.plugins if self.config else None 34 | severity = self.config.severity if self.config else None 35 | for plugin_cls in Plugin.__subclasses__(): 36 | name = plugin_cls.__name__ 37 | if include and name not in include: 38 | # Skip not needed plugins 39 | continue 40 | if exclude and name in exclude: 41 | # Skipped plugins 42 | continue 43 | if severity and not gixy.severity.is_acceptable( 44 | plugin_cls.severity, severity 45 | ): 46 | # Skip plugin by severity level 47 | continue 48 | if self.config and self.config.has_for(name): 49 | options = self.config.get_for(name) 50 | else: 51 | options = plugin_cls.options 52 | self._plugins.append(plugin_cls(options)) 53 | 54 | @property 55 | def plugins(self): 56 | if not self._plugins: 57 | self.init_plugins() 58 | return self._plugins 59 | 60 | @property 61 | def plugins_classes(self): 62 | self.import_plugins() 63 | return Plugin.__subclasses__() 64 | 65 | def get_plugins_descriptions(self): 66 | return map(lambda a: a.name, self.plugins) 67 | 68 | def audit(self, directive): 69 | for plugin in self.plugins: 70 | if plugin.directives and directive.name not in plugin.directives: 71 | continue 72 | plugin.audit(directive) 73 | 74 | def issues(self): 75 | result = [] 76 | for plugin in self.plugins: 77 | if not plugin.issues: 78 | continue 79 | result.extend(plugin.issues) 80 | return result 81 | -------------------------------------------------------------------------------- /gixy/core/severity.py: -------------------------------------------------------------------------------- 1 | UNSPECIFIED = 'UNSPECIFIED' 2 | LOW = 'LOW' 3 | MEDIUM = 'MEDIUM' 4 | HIGH = 'HIGH' 5 | ALL = [UNSPECIFIED, LOW, MEDIUM, HIGH] 6 | 7 | 8 | def is_acceptable(current_severity, min_severity): 9 | return ALL.index(current_severity) >= ALL.index(min_severity) 10 | -------------------------------------------------------------------------------- /gixy/core/sre_parse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/core/sre_parse/__init__.py -------------------------------------------------------------------------------- /gixy/core/utils.py: -------------------------------------------------------------------------------- 1 | def is_indexed_name(name): 2 | return isinstance(name, int) or (len(name) == 1 and '1' <= name <= '9') 3 | -------------------------------------------------------------------------------- /gixy/core/variable.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | from typing import Optional 4 | 5 | from gixy.core.regexp import Regexp 6 | from gixy.core.context import get_context 7 | 8 | LOG = logging.getLogger(__name__) 9 | # See ngx_http_script_compile in http/ngx_http_script.c 10 | EXTRACT_RE = re.compile(r"\$([1-9]|[a-z_][a-z0-9_]*|\{[a-z0-9_]+\})", re.IGNORECASE) 11 | 12 | 13 | def compile_script(script): 14 | """ 15 | Compile Nginx script to list of variables. 16 | Example: 17 | compile_script('http://$foo:$bar') -> 18 | [Variable('http://'), Variable($foo), Variable(':', Variable($bar). 19 | 20 | :param str script: Nginx scrip. 21 | :return Variable[]: list of variable. 22 | """ 23 | depends = [] 24 | context = get_context() 25 | for i, var in enumerate(EXTRACT_RE.split(str(script))): 26 | if i % 2: 27 | # Variable 28 | var = var.strip("{}\x20") 29 | var = context.get_var(var) 30 | if var: 31 | depends.append(var) 32 | elif var: 33 | # Literal 34 | depends.append(Variable(name=None, value=var, have_script=False)) 35 | return depends 36 | 37 | 38 | class Variable(object): 39 | def __init__( 40 | self, 41 | name, 42 | value=None, 43 | boundary: Optional[Regexp] = None, 44 | provider=None, 45 | have_script=True, 46 | ): 47 | """ 48 | Gixy Nginx variable class - parse and provide helpers to work with it. 49 | 50 | :param str|None name: variable name. 51 | :param str|Regexp value: variable value.. 52 | :param Regexp boundary: variable boundary set. 53 | :param Directive provider: directive that provide variable (e.g. if, location, rewrite, etc.). 54 | :param bool have_script: may variable have nginx script or not (mostly used to indicate a string literal). 55 | """ 56 | 57 | self.name = name 58 | self.value = value 59 | self.regexp = None 60 | self.depends = None 61 | self.boundary = boundary 62 | self.provider = provider 63 | if isinstance(value, Regexp): 64 | self.regexp = value 65 | elif have_script: 66 | self.depends = compile_script(value) 67 | 68 | def can_contain(self, char): 69 | """ 70 | Checks if variable can contain the specified char. 71 | 72 | :param str char: character to test. 73 | :return: True if variable can contain the specified char, False otherwise. 74 | """ 75 | 76 | # First of all check boundary set 77 | if self.boundary and not self.boundary.can_contain(char): 78 | return False 79 | 80 | # Then regexp 81 | if self.regexp: 82 | return self.regexp.can_contain(char, skip_literal=True) 83 | 84 | # Then dependencies 85 | if self.depends: 86 | return any(dep.can_contain(char) for dep in self.depends) 87 | 88 | # Otherwise user can't control value of this variable 89 | return False 90 | 91 | def can_startswith(self, char): 92 | """ 93 | Checks if variable can starts with the specified char. 94 | 95 | :param str char: character to test. 96 | :return: True if variable can starts with the specified char, False otherwise. 97 | """ 98 | 99 | # First of all check boundary set 100 | if self.boundary and not self.boundary.can_startswith(char): 101 | return False 102 | 103 | # Then regexp 104 | if self.regexp: 105 | return self.regexp.can_startswith(char) 106 | 107 | # Then dependencies 108 | if self.depends: 109 | return self.depends[0].can_startswith(char) 110 | 111 | # Otherwise user can't control value of this variable 112 | return False 113 | 114 | def must_contain(self, char): 115 | """ 116 | Checks if variable MUST contain the specified char. 117 | 118 | :param str char: character to test. 119 | :return: True if variable must contain the specified char, False otherwise. 120 | """ 121 | 122 | # First of all check boundary set 123 | if self.boundary and self.boundary.must_contain(char): 124 | return True 125 | 126 | # Then regexp 127 | if self.regexp: 128 | return self.regexp.must_contain(char) 129 | 130 | # Then dependencies 131 | if self.depends: 132 | return any(dep.must_contain(char) for dep in self.depends) 133 | 134 | # Otherwise checks literal 135 | return self.value and char in self.value 136 | 137 | def must_startswith(self, char): 138 | """ 139 | Checks if variable MUST starts with the specified char. 140 | 141 | :param str char: character to test. 142 | :return: True if variable must starts with the specified char. 143 | """ 144 | 145 | # First of all check boundary set 146 | if self.boundary and self.boundary.must_startswith(char): 147 | return True 148 | 149 | # Then regexp 150 | if self.regexp: 151 | return self.regexp.must_startswith(char) 152 | 153 | # Then dependencies 154 | if self.depends: 155 | return self.depends[0].must_startswith(char) 156 | 157 | # Otherwise checks literal 158 | return self.value and self.value[0] == char 159 | 160 | @property 161 | def providers(self): 162 | """ 163 | Returns list of variable provides. 164 | 165 | :return Directive[]: providers. 166 | """ 167 | result = [] 168 | if self.provider: 169 | result.append(self.provider) 170 | if self.depends: 171 | for dep in self.depends: 172 | result += dep.providers 173 | return result 174 | -------------------------------------------------------------------------------- /gixy/directives/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gixy.directives.directive import Directive 3 | 4 | DIRECTIVES = {} 5 | 6 | 7 | def import_directives(): 8 | files_list = os.listdir(os.path.dirname(__file__)) 9 | for directive_file in files_list: 10 | if not directive_file.endswith(".py") or directive_file.startswith('_'): 11 | continue 12 | __import__('gixy.directives.' + os.path.splitext(directive_file)[0], None, None, ['']) 13 | 14 | 15 | def get_all(): 16 | if len(DIRECTIVES): 17 | return DIRECTIVES 18 | 19 | import_directives() 20 | for klass in Directive.__subclasses__(): 21 | if not klass.nginx_name: 22 | continue 23 | DIRECTIVES[klass.nginx_name] = klass 24 | 25 | return DIRECTIVES 26 | -------------------------------------------------------------------------------- /gixy/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from gixy.formatters.base import BaseFormatter 3 | 4 | FORMATTERS = {} 5 | 6 | 7 | def import_formatters(): 8 | files_list = os.listdir(os.path.dirname(__file__)) 9 | for formatter_file in files_list: 10 | if not formatter_file.endswith(".py") or formatter_file.startswith('_'): 11 | continue 12 | __import__('gixy.formatters.' + os.path.splitext(formatter_file)[0], None, None, ['']) 13 | 14 | 15 | def get_all(): 16 | if len(FORMATTERS): 17 | return FORMATTERS 18 | 19 | import_formatters() 20 | for klass in BaseFormatter.__subclasses__(): 21 | FORMATTERS[klass.__name__.replace('Formatter', '').lower()] = klass 22 | 23 | return FORMATTERS 24 | -------------------------------------------------------------------------------- /gixy/formatters/_jinja.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from jinja2 import Environment, PackageLoader 3 | 4 | from gixy.utils.text import to_text 5 | 6 | 7 | def load_template(name): 8 | env = Environment(loader=PackageLoader('gixy', 'formatters/templates'), trim_blocks=True, lstrip_blocks=True) 9 | env.filters['to_text'] = to_text_filter 10 | return env.get_template(name) 11 | 12 | 13 | def to_text_filter(text): 14 | try: 15 | return text.encode('latin1').decode('utf-8') 16 | except UnicodeEncodeError: 17 | return to_text(text) 18 | -------------------------------------------------------------------------------- /gixy/formatters/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import gixy 4 | from gixy.directives import block 5 | 6 | 7 | class BaseFormatter(object): 8 | skip_parents = {block.Root, block.HttpBlock} 9 | 10 | def __init__(self): 11 | self.reports = {} 12 | self.stats = dict.fromkeys(gixy.severity.ALL, 0) 13 | 14 | def format_reports(self, reports, stats): 15 | raise NotImplementedError("Formatter must override format_reports function") 16 | 17 | def feed(self, path, manager): 18 | for severity in gixy.severity.ALL: 19 | self.stats[severity] += manager.stats[severity] 20 | 21 | self.reports[path] = [] 22 | for result in manager.results: 23 | report = self._prepare_result(manager.root, 24 | summary=result.summary, 25 | severity=result.severity, 26 | description=result.description, 27 | issues=result.issues, 28 | plugin=result.name, 29 | help_url=result.help_url) 30 | self.reports[path].extend(report) 31 | 32 | def flush(self): 33 | return self.format_reports(self.reports, self.stats) 34 | 35 | def _prepare_result(self, root, issues, severity, summary, description, plugin, help_url): 36 | result = {} 37 | for issue in issues: 38 | report = dict( 39 | plugin=plugin, 40 | summary=issue.summary or summary, 41 | severity=issue.severity or severity, 42 | description=issue.description or description, 43 | help_url=issue.help_url or help_url, 44 | reason=issue.reason or '', 45 | ) 46 | key = ''.join(report.values()) 47 | report['directives'] = issue.directives 48 | if key in result: 49 | result[key]['directives'].extend(report['directives']) 50 | else: 51 | result[key] = report 52 | 53 | for report in result.values(): 54 | if report['directives']: 55 | config = self._resolve_config(root, report['directives']) 56 | else: 57 | config = '' 58 | 59 | del report['directives'] 60 | report['config'] = config 61 | yield report 62 | 63 | def _resolve_config(self, root, directives): 64 | points = set() 65 | for directive in directives: 66 | points.add(directive) 67 | points.update(p for p in directive.parents) 68 | 69 | result = self._traverse_tree(root, points, 0) 70 | return '\n'.join(result) 71 | 72 | def _traverse_tree(self, tree, points, level): 73 | result = [] 74 | for leap in tree.children: 75 | if leap not in points: 76 | continue 77 | printable = type(leap) not in self.skip_parents 78 | # Special hack for includes 79 | # TODO(buglloc): fix me 80 | have_parentheses = type(leap) != block.IncludeBlock 81 | 82 | if printable: 83 | if leap.is_block: 84 | result.append('') 85 | directive = str(leap).replace('\n', '\n' + '\t' * (level + 1)) 86 | result.append('{indent:s}{dir:s}'.format(indent='\t' * level, dir=directive)) 87 | 88 | if leap.is_block: 89 | result.extend(self._traverse_tree(leap, points, level + 1 if printable else level)) 90 | if printable and have_parentheses: 91 | result.append('{indent:s}}}'.format(indent='\t' * level)) 92 | 93 | return result 94 | -------------------------------------------------------------------------------- /gixy/formatters/console.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from gixy.formatters.base import BaseFormatter 4 | from gixy.formatters._jinja import load_template 5 | 6 | 7 | class ConsoleFormatter(BaseFormatter): 8 | def __init__(self): 9 | super(ConsoleFormatter, self).__init__() 10 | self.template = load_template('console.j2') 11 | 12 | def format_reports(self, reports, stats): 13 | return self.template.render(reports=reports, stats=stats) 14 | -------------------------------------------------------------------------------- /gixy/formatters/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import json 4 | 5 | from gixy.formatters.base import BaseFormatter 6 | 7 | 8 | class JsonFormatter(BaseFormatter): 9 | def format_reports(self, reports, stats): 10 | result = [] 11 | for path, issues in reports.items(): 12 | for issue in issues: 13 | result.append(dict( 14 | path=path, 15 | plugin=issue['plugin'], 16 | summary=issue['summary'], 17 | severity=issue['severity'], 18 | description=issue['description'], 19 | reference=issue['help_url'], 20 | reason=issue['reason'], 21 | config=issue['config'] 22 | )) 23 | 24 | return json.dumps(result, sort_keys=True, indent=2, separators=(',', ': ')) 25 | -------------------------------------------------------------------------------- /gixy/formatters/templates/console.j2: -------------------------------------------------------------------------------- 1 | {% set colors = {'DEF': '\033[0m', 'TITLE': '\033[95m', 'UNSPECIFIED': '\033[0m', 'LOW': '\033[94m', 'MEDIUM': '\033[93m', 'HIGH': '\033[91m'} %} 2 | 3 | {{ colors.TITLE }}==================== Results ==================={{ colors.DEF }} 4 | {% for path, issues in reports.items() %} 5 | {% if reports|length > 1 %} 6 | File path: {{ path }} 7 | {% endif %} 8 | {% if not issues %} 9 | No issues found. 10 | 11 | {% else %} 12 | 13 | {% for issue in issues|sort(attribute='severity') %} 14 | {{ colors[issue.severity] }}>> Problem: [{{ issue.plugin }}] {{ issue.summary }}{{ colors.DEF }} 15 | {% if issue.description %} 16 | Description: {{ issue.description }} 17 | {% endif %} 18 | {% if issue.help_url %} 19 | Additional info: {{ issue.help_url }} 20 | {% endif %} 21 | {% if issue.reason %} 22 | Reason: {{ issue.reason }} 23 | {% endif %} 24 | Pseudo config: 25 | {{ issue.config | to_text }} 26 | 27 | {% if not loop.last %} 28 | ------------------------------------------------ 29 | 30 | {% endif %} 31 | {% endfor %} 32 | {% endif %} 33 | {% if not loop.last %} 34 | --------8<--------8<--------8<--------8<-------- 35 | {% endif %} 36 | {% endfor %} 37 | {% if stats %} 38 | {{ colors.TITLE }}==================== Summary ==================={{ colors.DEF }} 39 | Total issues: 40 | Unspecified: {{ stats.UNSPECIFIED }} 41 | Low: {{ stats.LOW }} 42 | Medium: {{ stats.MEDIUM }} 43 | High: {{ stats.HIGH }} 44 | {% endif %} 45 | -------------------------------------------------------------------------------- /gixy/formatters/templates/text.j2: -------------------------------------------------------------------------------- 1 | 2 | ==================== Results =================== 3 | {% for path, issues in reports.items() %} 4 | {% if reports|length > 1 %} 5 | File path: {{ path }} 6 | {% endif %} 7 | {% if not issues %} 8 | No issues found. 9 | 10 | {% else %} 11 | 12 | {% for issue in issues|sort(attribute='severity') %} 13 | >> Problem: [{{ issue.plugin }}] {{ issue.summary }} 14 | Severity: {{ issue.severity }} 15 | {% if issue.description %} 16 | Description: {{ issue.description }} 17 | {% endif %} 18 | {% if issue.help_url %} 19 | Additional info: {{ issue.help_url }} 20 | {% endif %} 21 | {% if issue.reason %} 22 | Reason: {{ issue.reason }} 23 | {% endif %} 24 | Pseudo config: 25 | {{ issue.config | to_text }} 26 | 27 | {% if not loop.last %} 28 | ------------------------------------------------ 29 | 30 | {% endif %} 31 | {% endfor %} 32 | {% endif %} 33 | {% if not loop.last %} 34 | --------8<--------8<--------8<--------8<-------- 35 | {% endif %} 36 | {% endfor %} 37 | {% if stats %} 38 | ==================== Summary =================== 39 | Total issues: 40 | Unspecified: {{ stats.UNSPECIFIED }} 41 | Low: {{ stats.LOW }} 42 | Medium: {{ stats.MEDIUM }} 43 | High: {{ stats.HIGH }} 44 | {% endif %} 45 | -------------------------------------------------------------------------------- /gixy/formatters/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from gixy.formatters.base import BaseFormatter 4 | from gixy.formatters._jinja import load_template 5 | 6 | 7 | class TextFormatter(BaseFormatter): 8 | def __init__(self): 9 | super(TextFormatter, self).__init__() 10 | self.template = load_template('text.j2') 11 | 12 | def format_reports(self, reports, stats): 13 | return self.template.render(reports=reports, stats=stats) 14 | -------------------------------------------------------------------------------- /gixy/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/parser/__init__.py -------------------------------------------------------------------------------- /gixy/parser/nginx_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import logging 4 | import fnmatch 5 | 6 | from pyparsing import ParseException 7 | from gixy.core.exceptions import InvalidConfiguration 8 | from gixy.parser import raw_parser 9 | from gixy.directives import block, directive 10 | from gixy.utils.text import to_native 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | 15 | class NginxParser(object): 16 | def __init__(self, cwd="", allow_includes=True): 17 | self.cwd = cwd 18 | self.configs = {} 19 | self.is_dump = False 20 | self.allow_includes = allow_includes 21 | self.directives = {} 22 | self.parser = raw_parser.RawParser() 23 | self._init_directives() 24 | 25 | def parse_file(self, path, root=None): 26 | LOG.debug("Parse file: {0}".format(path)) 27 | content = open(path).read() 28 | return self.parse(content=content, root=root, path_info=path) 29 | 30 | def parse(self, content, root=None, path_info=None): 31 | if not root: 32 | root = block.Root() 33 | try: 34 | parsed = self.parser.parse(content) 35 | except ParseException as e: 36 | error_msg = "char {char} (line:{line}, col:{col})".format( 37 | char=e.loc, line=e.lineno, col=e.col 38 | ) 39 | if path_info: 40 | LOG.error( 41 | 'Failed to parse config "{file}": {error}'.format( 42 | file=path_info, error=error_msg 43 | ) 44 | ) 45 | else: 46 | LOG.error("Failed to parse config: {error}".format(error=error_msg)) 47 | raise InvalidConfiguration(error_msg) 48 | 49 | if len(parsed) and parsed[0].getName() == "file_delimiter": 50 | # Were parse nginx dump 51 | LOG.info("Switched to parse nginx configuration dump.") 52 | root_filename = self._prepare_dump(parsed) 53 | self.is_dump = True 54 | self.cwd = os.path.dirname(root_filename) 55 | parsed = self.configs[root_filename] 56 | 57 | self.parse_block(parsed, root) 58 | return root 59 | 60 | def parse_block(self, parsed_block, parent): 61 | for parsed in parsed_block: 62 | parsed_type = parsed.getName() 63 | parsed_name = parsed[0] 64 | parsed_args = parsed[1:] 65 | if parsed_type == "include": 66 | # TODO: WTF?! 67 | self._resolve_include(parsed_args, parent) 68 | else: 69 | directive_inst = self.directive_factory( 70 | parsed_type, parsed_name, parsed_args 71 | ) 72 | if directive_inst: 73 | parent.append(directive_inst) 74 | 75 | def directive_factory(self, parsed_type, parsed_name, parsed_args): 76 | klass = self._get_directive_class(parsed_type, parsed_name) 77 | if not klass: 78 | return None 79 | 80 | if klass.is_block: 81 | args = [to_native(v).strip() for v in parsed_args[0]] 82 | children = parsed_args[1] 83 | 84 | inst = klass(parsed_name, args) 85 | self.parse_block(children, inst) 86 | return inst 87 | else: 88 | args = [to_native(v).strip() for v in parsed_args] 89 | return klass(parsed_name, args) 90 | 91 | def _get_directive_class(self, parsed_type, parsed_name): 92 | if ( 93 | parsed_type in self.directives 94 | and parsed_name in self.directives[parsed_type] 95 | ): 96 | return self.directives[parsed_type][parsed_name] 97 | elif parsed_type == "block": 98 | return block.Block 99 | elif parsed_type == "directive": 100 | return directive.Directive 101 | elif parsed_type == "unparsed_block": 102 | LOG.warning('Skip unparseable block: "%s"', parsed_name) 103 | return None 104 | else: 105 | return None 106 | 107 | def _init_directives(self): 108 | self.directives["block"] = block.get_overrides() 109 | self.directives["directive"] = directive.get_overrides() 110 | 111 | def _resolve_include(self, args, parent): 112 | pattern = args[0] 113 | # TODO(buglloc): maybe file providers? 114 | if self.is_dump: 115 | return self._resolve_dump_include(pattern=pattern, parent=parent) 116 | if not self.allow_includes: 117 | LOG.debug("Includes are disallowed, skip: {0}".format(pattern)) 118 | return 119 | 120 | return self._resolve_file_include(pattern=pattern, parent=parent) 121 | 122 | def _resolve_file_include(self, pattern, parent): 123 | path = os.path.join(self.cwd, pattern) 124 | exists = False 125 | for file_path in glob.iglob(path): 126 | if not os.path.exists(file_path): 127 | continue 128 | exists = True 129 | # parse the include into current context 130 | self.parse_file(file_path, parent) 131 | 132 | if not exists: 133 | LOG.warning("File not found: {0}".format(path)) 134 | 135 | def _resolve_dump_include(self, pattern, parent): 136 | path = os.path.join(self.cwd, pattern) 137 | founded = False 138 | for file_path, parsed in self.configs.items(): 139 | if fnmatch.fnmatch(file_path, path): 140 | founded = True 141 | include = block.IncludeBlock("include", [file_path]) 142 | parent.append(include) 143 | self.parse_block(parsed, include) 144 | 145 | if not founded: 146 | LOG.warning("File not found: {0}".format(path)) 147 | 148 | def _prepare_dump(self, parsed_block): 149 | filename = "" 150 | root_filename = "" 151 | for parsed in parsed_block: 152 | if parsed.getName() == "file_delimiter": 153 | if not filename: 154 | root_filename = parsed[0] 155 | filename = parsed[0] 156 | self.configs[filename] = [] 157 | continue 158 | self.configs[filename].append(parsed) 159 | return root_filename 160 | -------------------------------------------------------------------------------- /gixy/parser/raw_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import codecs 3 | import six 4 | try: 5 | from cached_property import cached_property 6 | except ImportError: 7 | from functools import cached_property 8 | 9 | from pyparsing import ( 10 | Literal, Suppress, White, Word, alphanums, Forward, Group, Optional, Combine, 11 | Keyword, OneOrMore, ZeroOrMore, Regex, QuotedString, nestedExpr, ParseResults) 12 | 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | class NginxQuotedString(QuotedString): 17 | def __init__(self, quoteChar): 18 | super(NginxQuotedString, self).__init__(quoteChar, escChar='\\', multiline=True) 19 | # Nginx parse quoted values in special manner: 20 | # '^https?:\/\/yandex\.ru\/\00\'\"' -> ^https?:\/\/yandex\.ru\/\00'" 21 | # TODO(buglloc): research and find another special characters! 22 | 23 | self.escCharReplacePattern = '\\\\(\'|")' 24 | 25 | 26 | class RawParser(object): 27 | """ 28 | A class that parses nginx configuration with pyparsing 29 | """ 30 | 31 | def parse(self, data): 32 | """ 33 | Returns the parsed tree. 34 | """ 35 | if isinstance(data, six.binary_type): 36 | if data[:3] == codecs.BOM_UTF8: 37 | encoding = 'utf-8-sig' 38 | else: 39 | encoding = 'latin1' 40 | content = data.decode(encoding).strip() 41 | else: 42 | content = data.strip() 43 | 44 | if not content: 45 | return ParseResults() 46 | 47 | return self.script.parseString(content, parseAll=True) 48 | 49 | @cached_property 50 | def script(self): 51 | # constants 52 | left_bracket = Suppress("{") 53 | right_bracket = Suppress("}") 54 | semicolon = Suppress(";") 55 | space = White().suppress() 56 | keyword = Word(alphanums + ".+-_/") 57 | path = Word(alphanums + ".-_/") 58 | variable = Word("$_-" + alphanums) 59 | value_wq = Regex(r'(?:\([^\s;]*\)|\$\{\w+\}|[^\s;(){}])+') 60 | value_sq = NginxQuotedString(quoteChar="'") 61 | value_dq = NginxQuotedString(quoteChar='"') 62 | value = (value_dq | value_sq | value_wq) 63 | # modifier for location uri [ = | ~ | ~* | ^~ ] 64 | location_modifier = ( 65 | Keyword("=") | 66 | Keyword("~*") | Keyword("~") | 67 | Keyword("^~")) 68 | # modifier for if statement 69 | if_modifier = Combine(Optional("!") + ( 70 | Keyword("=") | 71 | Keyword("~*") | Keyword("~") | 72 | (Literal("-") + (Literal("f") | Literal("d") | Literal("e") | Literal("x"))))) 73 | # This ugly workaround needed to parse unquoted regex with nested parentheses 74 | # so we capture all content between parentheses and then parse it :( 75 | # TODO(buglloc): may be use something better? 76 | condition_body = ( 77 | (if_modifier + Optional(space) + value) | 78 | (variable + Optional(space + if_modifier + Optional(space) + value)) 79 | ) 80 | condition = Regex(r'\((?:[^()\n\r\\]|(?:\(.*\))|(?:\\.))+?\)')\ 81 | .setParseAction(lambda s, l, t: condition_body.parseString(t[0][1:-1])) 82 | 83 | # rules 84 | include = ( 85 | Keyword("include") + 86 | space + 87 | value + 88 | semicolon 89 | )("include") 90 | 91 | directive = ( 92 | keyword + 93 | ZeroOrMore(space + value) + 94 | semicolon 95 | )("directive") 96 | 97 | file_delimiter = ( 98 | Suppress("# configuration file ") + 99 | path + 100 | Suppress(":") 101 | )("file_delimiter") 102 | 103 | comment = ( 104 | Regex(r"#.*") 105 | )("comment").setParseAction(_fix_comment) 106 | 107 | hash_value = Group( 108 | value + 109 | ZeroOrMore(space + value) + 110 | semicolon 111 | )("hash_value") 112 | 113 | generic_block = Forward() 114 | if_block = Forward() 115 | location_block = Forward() 116 | hash_block = Forward() 117 | unparsed_block = Forward() 118 | 119 | sub_block = OneOrMore(Group(if_block | 120 | location_block | 121 | hash_block | 122 | generic_block | 123 | include | 124 | directive | 125 | file_delimiter | 126 | comment | 127 | unparsed_block)) 128 | 129 | if_block << ( 130 | Keyword("if") + 131 | Group(condition) + 132 | Suppress(Optional(comment)) + 133 | Group( 134 | left_bracket + 135 | Optional(sub_block) + 136 | right_bracket) 137 | )("block") 138 | 139 | location_block << ( 140 | Keyword("location") + 141 | Group( 142 | Optional(space + location_modifier) + 143 | Optional(space) + value) + 144 | Suppress(Optional(comment)) + 145 | Group( 146 | left_bracket + 147 | Optional(sub_block) + 148 | right_bracket) 149 | )("block") 150 | 151 | hash_block << ( 152 | keyword + 153 | Group(OneOrMore(space + value)) + 154 | Group( 155 | left_bracket + 156 | Optional(OneOrMore(hash_value)) + 157 | right_bracket) 158 | )("block") 159 | 160 | generic_block << ( 161 | keyword + 162 | Group(ZeroOrMore(space + value)) + 163 | Suppress(Optional(comment)) + 164 | Group( 165 | left_bracket + 166 | Optional(sub_block) + 167 | right_bracket) 168 | )("block") 169 | 170 | unparsed_block << ( 171 | keyword + 172 | Group(ZeroOrMore(space + value)) + 173 | nestedExpr(opener="{", closer="}") 174 | )("unparsed_block") 175 | 176 | return sub_block 177 | 178 | 179 | def _fix_comment(string, location, tokens): 180 | """ 181 | Returns "cleared" comment text 182 | 183 | :param string: original parse string 184 | :param location: location in the string where matching started 185 | :param tokens: list of the matched tokens, packaged as a ParseResults_ object 186 | :return: list of the cleared comment tokens 187 | """ 188 | 189 | comment = tokens[0][1:].strip() 190 | return [comment] 191 | -------------------------------------------------------------------------------- /gixy/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/plugins/__init__.py -------------------------------------------------------------------------------- /gixy/plugins/add_header_content_type.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.directives.directive import AddHeaderDirective 3 | from gixy.plugins.plugin import Plugin 4 | 5 | 6 | class add_header_content_type(Plugin): 7 | """ 8 | Bad example: add_header Content-Type text/plain; 9 | Good example: default_type text/plain; 10 | """ 11 | 12 | summary = "Found add_header usage for setting Content-Type." 13 | severity = gixy.severity.LOW 14 | description = 'Target Content-Type in NGINX should not be set via "add_header"' 15 | help_url = "https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/add_header_content_type.md" 16 | directives = ["add_header"] 17 | 18 | def audit(self, directive: AddHeaderDirective): 19 | if directive.header == "content-type": 20 | reason = 'You probably want "default_type {default_type};" instead of "add_header" or "more_set_headers"'.format( 21 | default_type=directive.value 22 | ) 23 | self.add_issue(directive=directive, reason=reason) 24 | -------------------------------------------------------------------------------- /gixy/plugins/add_header_multiline.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.directives.directive import AddHeaderDirective 3 | from gixy.plugins.plugin import Plugin 4 | 5 | 6 | class add_header_multiline(Plugin): 7 | """ 8 | Insecure example: 9 | add_header Content-Security-Policy " 10 | default-src: 'none'; 11 | img-src data: https://mc.yandex.ru https://yastatic.net *.yandex.net https://mc.yandex.${tld} https://mc.yandex.ru; 12 | font-src data: https://yastatic.net;"; 13 | """ 14 | 15 | summary = "Found a multi-line header." 16 | severity = gixy.severity.LOW 17 | description = ( 18 | "Multi-line headers are deprecated (see RFC 7230). " 19 | "Some clients never supports them (e.g. IE/Edge)." 20 | ) 21 | help_url = "https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/addheadermultiline.md" 22 | directives = ["add_header", "more_set_headers"] 23 | 24 | def audit(self, directive: AddHeaderDirective): 25 | for header, value in directive.headers.items(): 26 | if "\n\x20" in value or "\n\t" in value: 27 | self.add_issue(directive=directive) 28 | break 29 | if "\n" in value: 30 | reason = ( 31 | 'A newline character is found in the directive "{directive}". The resulting header {header} will be ' 32 | "incomplete. Ensure the value is fit on a single line".format( 33 | directive=directive.name, header=header 34 | ) 35 | ) 36 | self.add_issue( 37 | severity=gixy.severity.HIGH, directive=directive, reason=reason 38 | ) 39 | break 40 | -------------------------------------------------------------------------------- /gixy/plugins/add_header_redefinition.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class add_header_redefinition(Plugin): 6 | """ 7 | Insecure example: 8 | server { 9 | add_header X-Content-Type-Options nosniff; 10 | location / { 11 | add_header X-Frame-Options DENY; 12 | } 13 | } 14 | """ 15 | summary = 'Nested "add_header" drops parent headers.' 16 | severity = gixy.severity.LOW 17 | description = ('"add_header" replaces ALL parent headers. ' 18 | 'See documentation: https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header') 19 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/addheaderredefinition.md' 20 | directives = ['server', 'location', 'if'] 21 | options = {'headers': set()} 22 | 23 | def __init__(self, config): 24 | super(add_header_redefinition, self).__init__(config) 25 | self.interesting_headers = self.config.get('headers') 26 | # Define secure headers that should escalate severity 27 | self.secure_headers = [ 28 | 'x-frame-options', 29 | 'x-content-type-options', 30 | 'x-xss-protection', 31 | 'content-security-policy', 32 | 'cache-control' 33 | ] 34 | 35 | def audit(self, directive): 36 | if not directive.is_block: 37 | # Skip all not block directives 38 | return 39 | 40 | actual_headers = get_headers(directive) 41 | if not actual_headers: 42 | return 43 | 44 | for parent in directive.parents: 45 | parent_headers = get_headers(parent) 46 | if not parent_headers: 47 | continue 48 | 49 | diff = parent_headers - actual_headers 50 | 51 | if len(self.interesting_headers): 52 | diff = diff & self.interesting_headers 53 | 54 | if len(diff): 55 | self._report_issue(directive, parent, diff) 56 | 57 | break 58 | 59 | def _report_issue(self, current, parent, diff): 60 | directives = [] 61 | # Add headers from parent level 62 | directives.extend(parent.find('add_header')) 63 | # Add headers from the current level 64 | directives.extend(current.find('add_header')) 65 | 66 | # Check if any dropped header is a secure header 67 | is_secure_header_dropped = any(header in self.secure_headers for header in diff) 68 | 69 | # Set severity based on whether a secure header was dropped 70 | issue_severity = gixy.severity.MEDIUM if is_secure_header_dropped else self.severity 71 | 72 | reason = 'Parent headers "{headers}" was dropped in current level'.format(headers='", "'.join(diff)) 73 | self.add_issue(directive=directives, reason=reason, severity=issue_severity) 74 | 75 | 76 | def get_headers(directive): 77 | headers = directive.find('add_header') 78 | if not headers: 79 | return set() 80 | 81 | return set(map(lambda d: d.header, headers)) 82 | -------------------------------------------------------------------------------- /gixy/plugins/alias_traversal.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class alias_traversal(Plugin): 6 | """ 7 | Insecure example: 8 | location /files { 9 | alias /home/; 10 | } 11 | """ 12 | summary = 'Path traversal via misconfigured alias.' 13 | severity = gixy.severity.HIGH 14 | description = 'Using alias in a prefixed location that doesn\'t ends with directory separator could lead to path ' \ 15 | 'traversal vulnerability. ' 16 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/aliastraversal.md' 17 | directives = ['alias'] 18 | 19 | def audit(self, directive): 20 | for location in directive.parents: 21 | if location.name != 'location': 22 | continue 23 | 24 | if not location.modifier or location.modifier == '^~': 25 | # We need non-strict prefixed locations 26 | if not location.path.endswith('/'): 27 | self.add_issue( 28 | severity=gixy.severity.HIGH if directive.path.endswith('/') else gixy.severity.MEDIUM, 29 | directive=[directive, location] 30 | ) 31 | break 32 | -------------------------------------------------------------------------------- /gixy/plugins/allow_without_deny.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class allow_without_deny(Plugin): 6 | """ 7 | Bad example: add_header Content-Type text/plain; 8 | Good example: default_type text/plain; 9 | """ 10 | summary = 'Found allow directive(s) without deny in the same context.' 11 | severity = gixy.severity.HIGH 12 | description = 'The "allow" directives should be typically accompanied by "deny all;" directive' 13 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/allow_without_deny.md' 14 | directives = ['allow'] 15 | 16 | def audit(self, directive): 17 | parent = directive.parent 18 | if not parent: 19 | return 20 | if directive.args == ['all']: 21 | # example, "allow all" in a nested location which allows access to otherwise forbidden parent location 22 | return 23 | deny_found = False 24 | for child in parent.children: 25 | if child.name == 'deny': 26 | deny_found = True 27 | if not deny_found: 28 | reason = 'You probably want "deny all;" after all the "allow" directives' 29 | self.add_issue( 30 | directive=directive, 31 | reason=reason 32 | ) 33 | 34 | 35 | -------------------------------------------------------------------------------- /gixy/plugins/error_log_off.py: -------------------------------------------------------------------------------- 1 | """Module for try_files_is_evil_too plugin.""" 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | 6 | 7 | class error_log_off(Plugin): 8 | """ 9 | Insecure example: 10 | location / { 11 | try_files $uri $uri/ /index.php$is_args$args; 12 | } 13 | """ 14 | 15 | summary = "The error_log directive does not take the off parameter." 16 | severity = gixy.severity.MEDIUM 17 | description = "The error_log directive should not be set to off. It should be set to a valid file path." 18 | help_url = "https://gixy.getpagespeed.com/en/plugins/error_log_off/" 19 | directives = ["error_log"] 20 | 21 | def audit(self, directive): 22 | if directive.args[0] == "off": 23 | self.add_issue( 24 | severity=self.severity, 25 | directive=[directive], 26 | reason="The error_log directive should not be set to off.", 27 | ) 28 | -------------------------------------------------------------------------------- /gixy/plugins/host_spoofing.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class host_spoofing(Plugin): 6 | """ 7 | Insecure example: 8 | proxy_set_header Host $http_host 9 | """ 10 | summary = 'The proxied Host header may be spoofed.' 11 | severity = gixy.severity.MEDIUM 12 | description = 'In most cases "$host" variable are more appropriate, just use it.' 13 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/hostspoofing.md' 14 | directives = ['proxy_set_header'] 15 | 16 | def audit(self, directive): 17 | name, value = directive.args 18 | if name.lower() != 'host': 19 | # Not a "Host" header 20 | return 21 | 22 | if value == '$http_host' or value.startswith('$arg_'): 23 | self.add_issue(directive=directive) 24 | -------------------------------------------------------------------------------- /gixy/plugins/http_splitting.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | from gixy.core.variable import compile_script 4 | 5 | 6 | class http_splitting(Plugin): 7 | r""" 8 | Insecure examples: 9 | rewrite ^ http://$host$uri; 10 | return 301 http://$host$uri; 11 | proxy_set_header "X-Original-Uri" $uri; 12 | proxy_pass http://upstream$document_uri; 13 | 14 | location ~ /proxy/(a|b)/(\W*)$ { 15 | set $path $2; 16 | proxy_pass http://storage/$path; 17 | } 18 | """ 19 | 20 | summary = 'Possible HTTP-Splitting vulnerability.' 21 | severity = gixy.severity.HIGH 22 | description = 'Using variables that can contain "\\n" or "\\r" may lead to http injection.' 23 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/httpsplitting.md' 24 | directives = ['rewrite', 'return', 'add_header', 'proxy_set_header', 'proxy_pass'] 25 | 26 | def audit(self, directive): 27 | value = _get_value(directive) 28 | if not value: 29 | return 30 | 31 | server_side = directive.name.startswith('proxy_') 32 | for var in compile_script(value): 33 | char = '' 34 | if var.can_contain('\n'): 35 | char = '\\n' 36 | elif not server_side and var.can_contain('\r'): 37 | char = '\\r' 38 | else: 39 | continue 40 | reason = 'At least variable "${var}" can contain "{char}"'.format(var=var.name, char=char) 41 | self.add_issue(directive=[directive] + var.providers, reason=reason) 42 | 43 | 44 | def _get_value(directive): 45 | if directive.name == 'proxy_pass' and len(directive.args) >= 1: 46 | return directive.args[0] 47 | elif len(directive.args) >= 2: 48 | return directive.args[1] 49 | return None 50 | -------------------------------------------------------------------------------- /gixy/plugins/if_is_evil.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class if_is_evil(Plugin): 6 | """ 7 | Insecure example: 8 | location /files { 9 | alias /home/; 10 | } 11 | """ 12 | summary = 'If is Evil... when used in location context.' 13 | severity = gixy.severity.HIGH 14 | description = 'Directive "if" has problems when used in location context, in some cases it does not do what you ' \ 15 | 'expect but something completely different instead. In some cases it even segfaults. It is ' \ 16 | 'generally a good idea to avoid it if possible.' 17 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/if_is_evil.md' 18 | directives = [] 19 | 20 | def audit(self, directive): 21 | parent = directive.parent 22 | # if immediate parent is not "if" break out 23 | if not parent or parent.name != 'if': 24 | return 25 | 26 | # "rewrite ... last" is safe 27 | if directive.name == 'rewrite' and directive.args[-1] == 'last': 28 | return 29 | 30 | # "return" is safe too 31 | if directive.name == 'return': 32 | return 33 | 34 | grandparent = parent.parent 35 | 36 | if grandparent and grandparent.name == 'location': 37 | reason = 'Directive "{directive}" is not safe to use in "if in location" context'.format(directive=directive.name) 38 | if directive.name == 'rewrite': 39 | reason = 'Directive "rewrite" is only safe to use in "if in location" context when "last" ' \ 40 | 'argument is used' 41 | self.add_issue( 42 | severity=gixy.severity.HIGH, 43 | directive=[directive, parent], 44 | reason=reason 45 | ) 46 | 47 | 48 | -------------------------------------------------------------------------------- /gixy/plugins/origins.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | from gixy.core.regexp import Regexp 6 | 7 | LOG = logging.getLogger(__name__) 8 | 9 | 10 | class origins(Plugin): 11 | r""" 12 | Insecure example: 13 | if ($http_referer !~ "^https?://([^/]+metrika.*yandex\.ru/"){ 14 | add_header X-Frame-Options SAMEORIGIN; 15 | } 16 | """ 17 | summary = 'Validation regex for "origin" or "referrer" matches untrusted domain.' 18 | severity = gixy.severity.MEDIUM 19 | description = 'Improve the regular expression to match only trusted referrers.' 20 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/origins.md' 21 | directives = ['if'] 22 | options = { 23 | 'domains': ['*'], 24 | 'https_only': False 25 | } 26 | 27 | def __init__(self, config): 28 | super(origins, self).__init__(config) 29 | if self.config.get('domains') and self.config.get('domains')[0] and self.config.get('domains')[0] != '*': 30 | domains = '|'.join(re.escape(d) for d in self.config.get('domains')) 31 | else: 32 | domains = r'[^/.]*\.[^/]{2,7}' 33 | 34 | scheme = 'https{http}'.format(http=('?' if not self.config.get('https_only') else '')) 35 | regex = r'^{scheme}://(?:[^/.]*\.){{0,10}}(?P{domains})(?::\d*)?(?:/|\?|$)'.format( 36 | scheme=scheme, 37 | domains=domains 38 | ) 39 | self.valid_re = re.compile(regex) 40 | 41 | def audit(self, directive): 42 | if directive.operand not in ['~', '~*', '!~', '!~*']: 43 | # Not regexp 44 | return 45 | 46 | if directive.variable not in ['$http_referer', '$http_origin']: 47 | # Not interesting 48 | return 49 | 50 | invalid_referers = set() 51 | regexp = Regexp(directive.value, case_sensitive=(directive.operand in ['~', '!~'])) 52 | for value in regexp.generate('/', anchored=True): 53 | if value.startswith('^'): 54 | value = value[1:] 55 | else: 56 | value = 'http://evil.com/' + value 57 | 58 | if value.endswith('$'): 59 | value = value[:-1] 60 | elif not value.endswith('/'): 61 | value += '.evil.com' 62 | 63 | valid = self.valid_re.match(value) 64 | if not valid or valid.group('domain') == 'evil.com': 65 | invalid_referers.add(value) 66 | 67 | if invalid_referers: 68 | invalid_referers = '", "'.join(invalid_referers) 69 | name = 'origin' if directive.variable == '$http_origin' else 'referrer' 70 | severity = gixy.severity.HIGH if directive.variable == '$http_origin' else gixy.severity.MEDIUM 71 | reason = 'Regex matches "{value}" as a valid {name}.'.format(value=invalid_referers, name=name) 72 | self.add_issue(directive=directive, reason=reason, severity=severity) 73 | -------------------------------------------------------------------------------- /gixy/plugins/plugin.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.core.issue import Issue 3 | 4 | 5 | class Plugin(object): 6 | summary = '' 7 | description = '' 8 | help_url = '' 9 | severity = gixy.severity.UNSPECIFIED 10 | directives = [] 11 | options = {} 12 | 13 | def __init__(self, config): 14 | self._issues = [] 15 | self.config = config 16 | 17 | def add_issue(self, directive, summary=None, severity=None, description=None, reason=None, help_url=None): 18 | self._issues.append(Issue(self, directives=directive, summary=summary, severity=severity, 19 | description=description, reason=reason, help_url=help_url)) 20 | 21 | def audit(self, directive): 22 | pass 23 | 24 | @property 25 | def issues(self): 26 | return self._issues 27 | 28 | @property 29 | def name(self): 30 | return self.__class__.__name__ 31 | -------------------------------------------------------------------------------- /gixy/plugins/proxy_pass_normalized.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlparse 3 | 4 | import gixy 5 | from gixy.plugins.plugin import Plugin 6 | 7 | 8 | class proxy_pass_normalized(Plugin): 9 | r""" 10 | This plugin detects if there is any path component (slash or more) 11 | after the host in a proxy_pass directive. 12 | Example flagged directives: 13 | proxy_pass http://backend/; 14 | proxy_pass http://backend/foo/bar; 15 | """ 16 | 17 | summary = "Detect path after host in proxy_pass (potential URL decoding issue)" 18 | severity = gixy.severity.MEDIUM 19 | description = "A path (beginning with a slash) after the host in proxy_pass leads to the path being decoded and normalized before proxying downstream, leading to unexpected behavior related to encoded slashes like %2F..%2F. Likewise, the usage of 'rewrite ^ $request_uri;' without using '$1' or '$uri' (or another captured group) in the path of proxy_pass leads to double-encoding of paths." 20 | help_url = "https://joshua.hu/proxy-pass-nginx-decoding-normalizing-url-path-dangerous#nginx-proxy_pass" 21 | directives = ["proxy_pass"] 22 | 23 | def __init__(self, config): 24 | super(proxy_pass_normalized, self).__init__(config) 25 | self.num_pattern = re.compile(r"\$\d+") 26 | 27 | def audit(self, directive): 28 | proxy_pass_args = directive.args 29 | rewrite_fail = False 30 | parent = directive.parent 31 | 32 | if not proxy_pass_args: 33 | return 34 | 35 | if not parent or parent.name != 'location' or parent.modifier == '=': 36 | return 37 | 38 | if proxy_pass_args[0].startswith("$") and '/' not in proxy_pass_args[0]: 39 | # If proxy pass destination is defined by only a variable, it is not possible to check for path normalization issues 40 | return 41 | 42 | parsed = urlparse(proxy_pass_args[0]) 43 | 44 | if not parsed: 45 | return 46 | 47 | host = parsed.netloc 48 | path = parsed.path 49 | if host == "unix:": 50 | path_parts = path.split(":", 1) 51 | host = path_parts[0] 52 | path = path_parts[1] if len(path_parts) > 1 else "" 53 | 54 | for rewrite in directive.find_directives_in_scope("rewrite"): 55 | if ( 56 | getattr(rewrite, "pattern", None) == "^" 57 | and getattr(rewrite, "replace", None) == "$request_uri" 58 | ): 59 | if path: 60 | # Check for $uri or any numbered variable in the path. 61 | if "$uri" in path or self.num_pattern.search(path): 62 | return 63 | rewrite_fail = True 64 | break 65 | else: 66 | if "$uri" in host or self.num_pattern.search(host): 67 | return 68 | rewrite_fail = True 69 | break 70 | 71 | if not path and not rewrite_fail: 72 | return 73 | 74 | self.add_issue( 75 | severity=self.severity, 76 | directive=[directive, directive.parent], 77 | reason=( 78 | "Found a path after the host in proxy_pass, without using $request_uri and a variable (such as $1 or $uri). " 79 | "This can lead to path decoding issues or double-encoding issues." 80 | ), 81 | ) 82 | -------------------------------------------------------------------------------- /gixy/plugins/regex_redos.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import gixy 3 | from gixy.plugins.plugin import Plugin 4 | 5 | 6 | class regex_redos(Plugin): 7 | r""" 8 | This plugin checks all directives for regular expressions that may be 9 | vulnerable to ReDoS (Regular Expression Denial of Service). ReDoS 10 | vulnerabilities may be used to overwhelm nginx servers with minimal 11 | resources from an attacker. 12 | 13 | Example of a vulnerable directive: 14 | location ~ ^/(a|aa|aaa|aaaa)+$ 15 | 16 | Accessing the above location with a path such as 17 | /aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab 18 | can result in catastrophic backtracking. 19 | 20 | This plugin relies on an external, public API to determine vulnerability. 21 | Because of this network-dependence, and the fact that potentially private 22 | expressions are sent over the network, usage of this plugin requires 23 | the --regex-redos-url flag. This flag must specify the full URL to a 24 | service which can be queried with expressions, responding with a report 25 | matching the https://github.com/makenowjust-labs/recheck format. 26 | 27 | An implementation of a compatible server: 28 | https://github.com/MegaManSec/recheck-http-api 29 | """ 30 | 31 | summary = ( 32 | 'Detect directives with regexes that are vulnerable to ' 33 | 'Regular Expression Denial of Service (ReDoS).' 34 | ) 35 | severity = gixy.severity.HIGH 36 | unknown_severity = gixy.severity.UNSPECIFIED 37 | description = ( 38 | 'Regular expressions with the potential for catastrophic backtracking ' 39 | 'allow an nginx server to be denial-of-service attacked with very low ' 40 | 'resources (also known as ReDoS).' 41 | ) 42 | help_url = 'https://joshua.hu/regex-redos-recheck-nginx-gixy' 43 | directives = ['location'] # XXX: Missing server_name, rewrite, if, map, proxy_redirect 44 | options = { 45 | 'url': "" 46 | } 47 | skip_test = True 48 | 49 | def __init__(self, config): 50 | super(regex_redos, self).__init__(config) 51 | self.redos_server = self.config.get('url') 52 | 53 | def audit(self, directive): 54 | # If we have no ReDoS check URL, skip. 55 | if not self.redos_server: 56 | return 57 | 58 | # Only process directives that have regex modifiers. 59 | if directive.modifier not in ('~', '~*'): 60 | return 61 | 62 | regex_pattern = directive.path 63 | fail_reason = f'Could not check regex {regex_pattern} for ReDoS.' 64 | 65 | modifier = "" if directive.modifier == "~" else "i" 66 | json_data = {"1": {"pattern": regex_pattern, "modifier": modifier}} 67 | 68 | # Attempt to contact the ReDoS check server. 69 | try: 70 | response = requests.post( 71 | self.redos_server, 72 | json=json_data, 73 | headers={"Content-Type": "application/json"}, 74 | timeout=60 75 | ) 76 | except requests.RequestException: 77 | self.add_issue(directive=directive, reason=fail_reason, severity=self.unknown_severity) 78 | return 79 | 80 | # If we get a non-200 response, skip. 81 | if response.status_code != 200: 82 | self.add_issue(directive=directive, reason=fail_reason, severity=self.unknown_severity) 83 | return 84 | 85 | # Attempt to parse the JSON response. 86 | try: 87 | response_json = response.json() 88 | except ValueError: 89 | self.add_issue(directive=directive, reason=fail_reason, severity=self.unknown_severity) 90 | return 91 | 92 | # Ensure the expected data structure is present and matches the pattern. 93 | if ( 94 | "1" not in response_json or 95 | response_json["1"] is None or 96 | "source" not in response_json["1"] or 97 | response_json["1"]["source"] != regex_pattern 98 | ): 99 | self.add_issue(directive=directive, reason=fail_reason, severity=self.unknown_severity) 100 | return 101 | 102 | recheck = response_json["1"] 103 | status = recheck.get("status") 104 | 105 | # If status is neither 'vulnerable' nor 'unknown', the expression is safe. 106 | if status not in ("vulnerable", "unknown"): 107 | return 108 | 109 | # If the status is unknown, add a low-severity issue (likely the server timed out) 110 | if status == "unknown": 111 | reason = f'Could not check complexity of regex {regex_pattern}.' 112 | self.add_issue(directive=directive, reason=reason, severity=self.unknown_severity) 113 | return 114 | 115 | # Status is 'vulnerable' here. Report as a high-severity issue. 116 | complexity_summary = recheck.get("complexity", {}).get("summary", "unknown") 117 | reason = f'Regex is vulnerable to {complexity_summary} ReDoS: {regex_pattern}.' 118 | self.add_issue(directive=directive, reason=reason, severity=self.severity) 119 | -------------------------------------------------------------------------------- /gixy/plugins/resolver_external.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class resolver_external(Plugin): 6 | """ 7 | Syntax for the directive: resolver 127.0.0.1 [::1]:5353 valid=30s; 8 | """ 9 | summary = 'Do not use external nameservers for "resolver"' 10 | severity = gixy.severity.HIGH 11 | description = 'Using external nameservers allows someone to send spoofed DNS replies to poison the resolver ' \ 12 | 'cache, causing NGINX to proxy HTTP requests to an arbitrary upstream server.' 13 | help_url = 'https://blog.zorinaq.com/nginx-resolver-vulns/' 14 | directives = ['resolver'] 15 | 16 | def audit(self, directive): 17 | bad_nameservers = directive.get_external_nameservers() 18 | if bad_nameservers: 19 | self.add_issue( 20 | severity=gixy.severity.HIGH, 21 | directive=[directive, directive.parent], 22 | reason="Found use of external DNS servers {dns_servers}".format( 23 | dns_servers=", ".join(bad_nameservers) 24 | ) 25 | ) 26 | 27 | 28 | -------------------------------------------------------------------------------- /gixy/plugins/ssrf.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | from gixy.core.context import get_context 6 | from gixy.core.variable import compile_script 7 | 8 | 9 | class ssrf(Plugin): 10 | """ 11 | Insecure examples: 12 | location ~ /proxy/(.*)/(.*)/(.*)$ { 13 | set $scheme $1; 14 | set $host $2; 15 | set $path $3; 16 | proxy_pass $scheme://$host/$path; 17 | } 18 | 19 | location /proxy/ { 20 | proxy_pass $arg_some; 21 | } 22 | """ 23 | 24 | summary = 'Possible SSRF (Server Side Request Forgery) vulnerability.' 25 | severity = gixy.severity.HIGH 26 | description = 'The configuration may allow attacker to create a arbitrary requests from the vulnerable server.' 27 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/ssrf.md' 28 | directives = ['proxy_pass'] 29 | 30 | def __init__(self, config): 31 | super(ssrf, self).__init__(config) 32 | self.parse_uri_re = re.compile(r'(?P[^?#/)]+://)?(?P[^?#/)]+)') 33 | 34 | def audit(self, directive): 35 | value = directive.args[0] 36 | if not value: 37 | return 38 | 39 | context = get_context() 40 | if context.block.name == 'location' and context.block.is_internal: 41 | # Exclude internal locations 42 | return 43 | 44 | parsed = self.parse_uri_re.match(value) 45 | if not parsed: 46 | return 47 | 48 | res = self._check_script(parsed.group('scheme'), directive) 49 | if not res: 50 | self._check_script(parsed.group('host'), directive) 51 | 52 | def _check_script(self, script, directive): 53 | for var in compile_script(script): 54 | if var.must_contain('/'): 55 | # Skip variable checks 56 | return False 57 | if var.can_contain('.'): 58 | # Yay! Our variable can contain any symbols! 59 | reason = 'At least variable "${var}" can contain untrusted user input'.format(var=var.name) 60 | self.add_issue(directive=[directive] + var.providers, reason=reason) 61 | return True 62 | return False 63 | -------------------------------------------------------------------------------- /gixy/plugins/try_files_is_evil_too.py: -------------------------------------------------------------------------------- 1 | """Module for try_files_is_evil_too plugin.""" 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | 6 | 7 | class try_files_is_evil_too(Plugin): 8 | """ 9 | Insecure example: 10 | location / { 11 | try_files $uri $uri/ /index.php$is_args$args; 12 | } 13 | """ 14 | 15 | summary = "The try_files directive is evil without open_file_cache" 16 | severity = gixy.severity.MEDIUM 17 | description = "The try_files directive introduces performance overhead. " 18 | help_url = "https://www.getpagespeed.com/server-setup/nginx-try_files-is-evil-too" 19 | directives = ["try_files"] 20 | 21 | def audit(self, directive): 22 | # search for open_file_cache ...; on the same or higher level 23 | open_file_cache = directive.find_single_directive_in_scope("open_file_cache") 24 | if not open_file_cache or open_file_cache.args[0] == "off": 25 | self.add_issue( 26 | severity=gixy.severity.MEDIUM, 27 | directive=[directive], 28 | reason="The try_files directive introduces performance overhead without open_file_cache", 29 | ) 30 | -------------------------------------------------------------------------------- /gixy/plugins/unanchored_regex.py: -------------------------------------------------------------------------------- 1 | """Module for unanchored_regex plugin.""" 2 | 3 | import gixy 4 | from gixy.directives.block import LocationBlock 5 | from gixy.plugins.plugin import Plugin 6 | 7 | 8 | class unanchored_regex(Plugin): 9 | r""" 10 | Insecure example: 11 | location ~ \.php { 12 | 13 | } 14 | """ 15 | 16 | summary = "Regular expressions without anchors can be slow" 17 | severity = gixy.severity.LOW 18 | description = "Regular expressions without anchors can be slow" 19 | help_url = "https://gixy.getpagespeed.com/en/plugins/unanchored_regex/" 20 | directives = ["location"] 21 | 22 | def audit(self, directive: LocationBlock): 23 | # check for `location ~ \.php` instead of `location ~ \.php$` 24 | if not directive.is_regex: 25 | return 26 | if directive.needs_anchor(): 27 | self.add_issue( 28 | severity=gixy.severity.LOW, 29 | directive=[directive], 30 | reason="Regular expressions without anchors can be slow", 31 | ) 32 | -------------------------------------------------------------------------------- /gixy/plugins/valid_referers.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class valid_referers(Plugin): 6 | """ 7 | Insecure example: 8 | valid_referers none server_names *.webvisor.com; 9 | """ 10 | summary = 'Used "none" as valid referer.' 11 | severity = gixy.severity.HIGH 12 | description = 'Never trust undefined referer.' 13 | help_url = 'https://github.com/dvershinin/gixy/blob/master/docs/en/plugins/validreferers.md' 14 | directives = ['valid_referers'] 15 | 16 | def audit(self, directive): 17 | if 'none' in directive.args: 18 | self.add_issue(directive=directive) 19 | -------------------------------------------------------------------------------- /gixy/plugins/version_disclosure.py: -------------------------------------------------------------------------------- 1 | import gixy 2 | from gixy.plugins.plugin import Plugin 3 | 4 | 5 | class version_disclosure(Plugin): 6 | """ 7 | Syntax for the directive: server_tokens off; 8 | """ 9 | summary = 'Do not enable server_tokens on or server_tokens build' 10 | severity = gixy.severity.HIGH 11 | description = ("Using server_tokens on; or server_tokens build; allows an " 12 | "attacker to learn the version of NGINX you are running, which can " 13 | "be used to exploit known vulnerabilities.") 14 | help_url = 'https://gixy.getpagespeed.com/en/plugins/version_disclosure/' 15 | directives = ['server_tokens'] 16 | 17 | def audit(self, directive): 18 | if directive.args[0] in ['on', 'build']: 19 | self.add_issue( 20 | severity=gixy.severity.HIGH, 21 | directive=[directive, directive.parent], 22 | reason="Using server_tokens value which promotes information disclosure" 23 | ) 24 | -------------------------------------------------------------------------------- /gixy/plugins/worker_rlimit_nofile_vs_connections.py: -------------------------------------------------------------------------------- 1 | """Module for try_files_is_evil_too plugin.""" 2 | 3 | import gixy 4 | from gixy.plugins.plugin import Plugin 5 | 6 | 7 | class worker_rlimit_nofile_vs_connections(Plugin): 8 | """ 9 | Insecure example: 10 | worker_connections 1024; 11 | worker_rlimit_nofile 1024; # should be higher than worker_connections 12 | """ 13 | 14 | summary = ( 15 | "The worker_rlimit_nofile should be at least twice than worker_connections." 16 | ) 17 | severity = gixy.severity.MEDIUM 18 | description = ( 19 | "The worker_rlimit_nofile should be at least twice than worker_connections." 20 | ) 21 | help_url = ( 22 | "https://gixy.getpagespeed.com/en/plugins/worker_rlimit_nofile_vs_connections/" 23 | ) 24 | directives = ["worker_connections"] 25 | 26 | def audit(self, directive): 27 | # get worker_connections value 28 | worker_connections = directive.args[0] 29 | worker_rlimit_nofile_directive = directive.find_single_directive_in_scope( 30 | "worker_rlimit_nofile" 31 | ) 32 | if worker_rlimit_nofile_directive: 33 | worker_rlimit_nofile = worker_rlimit_nofile_directive.args[0] 34 | if int(worker_rlimit_nofile) < int(worker_connections) * 2: 35 | self.add_issue( 36 | severity=self.severity, 37 | directive=[directive, worker_rlimit_nofile_directive], 38 | reason=( 39 | "worker_rlimit_nofile should be at least twice than worker_connections" 40 | ), 41 | ) 42 | else: 43 | self.add_issue( 44 | severity=self.severity, 45 | directive=[directive], 46 | reason=( 47 | "Missing worker_rlimit_nofile with at least twice the value of worker_connections" 48 | ), 49 | ) 50 | -------------------------------------------------------------------------------- /gixy/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/gixy/utils/__init__.py -------------------------------------------------------------------------------- /gixy/utils/text.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from six import PY3, text_type, binary_type 3 | 4 | 5 | def to_bytes(obj, encoding='latin1', errors='strict', nonstring='replace'): 6 | if isinstance(obj, binary_type): 7 | return obj 8 | 9 | if isinstance(obj, text_type): 10 | try: 11 | # Try this first as it's the fastest 12 | return obj.encode(encoding, errors) 13 | except UnicodeEncodeError: 14 | return b'failed_to_encode' 15 | 16 | if nonstring == 'simplerepr': 17 | try: 18 | 19 | value = str(obj) 20 | except UnicodeError: 21 | try: 22 | value = repr(obj) 23 | except UnicodeError: 24 | # Giving up 25 | return b'failed_to_encode' 26 | elif nonstring == 'passthru': 27 | return obj 28 | elif nonstring == 'replace': 29 | return b'failed_to_encode' 30 | elif nonstring == 'strict': 31 | raise TypeError('obj must be a string type') 32 | else: 33 | raise TypeError('Invalid value %s for to_bytes\' nonstring parameter' % nonstring) 34 | 35 | return to_bytes(value, encoding, errors) 36 | 37 | 38 | def to_text(obj, encoding='latin1', errors='strict', nonstring='replace'): 39 | if isinstance(obj, text_type): 40 | return obj 41 | 42 | if isinstance(obj, binary_type): 43 | try: 44 | return obj.decode(encoding, errors) 45 | except UnicodeEncodeError: 46 | return u'failed_to_encode' 47 | 48 | if nonstring == 'simplerepr': 49 | try: 50 | value = str(obj) 51 | except UnicodeError: 52 | try: 53 | value = repr(obj) 54 | except UnicodeError: 55 | # Giving up 56 | return u'failed_to_encode' 57 | elif nonstring == 'passthru': 58 | return obj 59 | elif nonstring == 'replace': 60 | return u'failed_to_encode' 61 | elif nonstring == 'strict': 62 | raise TypeError('obj must be a string type') 63 | else: 64 | raise TypeError('Invalid value %s for to_text\'s nonstring parameter' % nonstring) 65 | 66 | return to_text(value, encoding, errors) 67 | 68 | 69 | if PY3: 70 | to_native = to_text 71 | else: 72 | to_native = to_bytes 73 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Gixy docs 2 | site_description: "Automatic documentation from sources, for Gixy." 3 | site_url: https://gixy.getpagespeed.com/ 4 | repo_url: https://github.com/dvershinin/gixy 5 | theme: 6 | name: "material" 7 | palette: 8 | scheme: slate 9 | primary: teal 10 | accent: purple 11 | features: 12 | - navigation.expand 13 | plugins: 14 | - search 15 | nav: 16 | - Overview: index.md 17 | - Plugins: 18 | - Server Side Request Forgery: en/plugins/ssrf.md 19 | - HTTP Splitting: en/plugins/httpsplitting.md 20 | - Problems with referrer/origin validation: en/plugins/origins.md 21 | - Redefining of response headers by "add_header" directive: en/plugins/addheaderredefinition.md 22 | - Request's Host header forgery: en/plugins/hostspoofing.md 23 | - none in valid_referers: en/plugins/validreferers.md 24 | - Multiline response headers: en/plugins/addheadermultiline.md 25 | - Path traversal via misconfigured alias: en/plugins/aliastraversal.md 26 | - If is evil when used in location context: en/plugins/if_is_evil.md 27 | - Allow specified without deny: en/plugins/allow_without_deny.md 28 | - Setting Content-Type via add_header: en/plugins/add_header_content_type.md 29 | - Using external DNS nameservers: https://blog.zorinaq.com/nginx-resolver-vulns/ 30 | - Unsafe path decoding with proxy_pass: https://joshua.hu/proxy-pass-nginx-decoding-normalizing-url-path-dangerous#nginx-proxy_pass 31 | - Version Disclosure: en/plugins/version_disclosure.md 32 | - 'Blog': 'https://www.getpagespeed.com/posts' 33 | markdown_extensions: 34 | - admonition 35 | - markdown_include.include 36 | - pymdownx.emoji 37 | - pymdownx.magiclink 38 | - pymdownx.superfences 39 | - pymdownx.tabbed 40 | - pymdownx.tasklist 41 | - pymdownx.snippets: 42 | check_paths: true 43 | - toc: 44 | permalink: "¤" 45 | extra: 46 | generator: false 47 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=7.0.0 2 | coverage>=4.3 3 | flake8>=3.2 4 | tox>=2.7.0 5 | pytest-xdist 6 | setuptools 7 | twine 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyparsing>=1.5.5,<=2.4.7 2 | cached-property>=1.2.0 3 | argparse>=1.4.0 4 | six>=1.1.0 5 | Jinja2>=2.8 6 | ConfigArgParse>=0.11.0 7 | -------------------------------------------------------------------------------- /rpm/gixy.spec: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | 3 | Summary: Nginx configuration static analyzer 4 | Name: gixy 5 | Version: 0.1.5 6 | Release: 0%{?dist} 7 | License: MPLv2.0 8 | Group: Development/Utilities 9 | URL: https://github.com/dvershinin/gixy 10 | 11 | Source: https://github.com/yandex/%{name}/archive/v%{version}.tar.gz 12 | 13 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 14 | 15 | BuildArch: noarch 16 | 17 | BuildRequires: python-devel python-setuptools 18 | 19 | Requires: python-setuptools python-six >= 1.1.0 python-jinja >= 2.8 20 | Requires: python2-cached_property >= 1.2.0 python2-configargparse >= 0.11.0 21 | Requires: python-argparse >= 1.4.0 pyparsing >= 1.5.5 python-markupsafe 22 | 23 | Provides: %{name} = %{verion}-%{release} 24 | 25 | ######################################################################################## 26 | 27 | %description 28 | Gixy is a tool to analyze Nginx configuration. The main goal of Gixy is to prevent 29 | misconfiguration and automate flaw detection. 30 | 31 | ######################################################################################## 32 | 33 | %prep 34 | %setup -qn %{name}-%{version} 35 | 36 | 37 | %build 38 | python setup.py build 39 | 40 | %install 41 | rm -rf %{buildroot} 42 | python setup.py install --prefix=%{_prefix} \ 43 | --root=%{buildroot} 44 | 45 | ######################################################################################## 46 | 47 | %files 48 | %defattr(-,root,root,-) 49 | %doc LICENSE AUTHORS README.md docs/* 50 | %{python_sitelib}/* 51 | %{_bindir}/%{name} 52 | 53 | ######################################################################################## 54 | 55 | %changelog 56 | * Sun May 21 2017 Yandex Team - 0.1.5-0 57 | - Supported Python 2.6 58 | - Supported multiple config files scanning 59 | - Fixed summary count 60 | - Fixed symlink resolution 61 | - Minor improvements and fixes 62 | 63 | * Sun May 14 2017 Yandex Team - 0.1.4-0 64 | - Allow processing stdin, file descriptors 65 | - Fixed configuration parser 66 | 67 | * Thu May 11 2017 Yandex Team - 0.1.3-0 68 | - Uses english versions in plugins references 69 | 70 | * Tue May 02 2017 Yandex Team - 0.1.2-0 71 | - Fixed blank comments parsing 72 | - Added "auth_request_set" directive 73 | 74 | * Sat Apr 29 2017 Yandex Team - 0.1.1-0 75 | - Initial build 76 | -------------------------------------------------------------------------------- /rpm/python-argparse.spec: -------------------------------------------------------------------------------- 1 | ######################################################################################## 2 | 3 | %{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")} 4 | 5 | ######################################################################################## 6 | 7 | %define pkg_name argparse 8 | %define pkg_version r140 9 | 10 | ######################################################################################## 11 | 12 | Summary: Python command-line parsing library 13 | Name: python-argparse 14 | Version: 1.4.0 15 | Release: 0%{?dist} 16 | License: Python License 17 | Group: Development/Libraries 18 | URL: https://github.com/ThomasWaldmann/argparse 19 | 20 | Source: https://github.com/ThomasWaldmann/%{pkg_name}/archive/%{pkg_version}.tar.gz 21 | 22 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 23 | 24 | BuildArch: noarch 25 | 26 | BuildRequires: python >= 2.3 python-setuptools 27 | 28 | Requires: python >= 2.3 python-setuptools 29 | 30 | Provides: %{name} = %{verion}-%{release} 31 | 32 | ######################################################################################## 33 | 34 | %description 35 | The argparse module makes it easy to write user friendly command line interfaces. 36 | 37 | The program defines what arguments it requires, and argparse will figure out 38 | how to parse those out of sys.argv. The argparse module also automatically 39 | generates help and usage messages and issues errors when users give the program 40 | invalid arguments. 41 | 42 | As of Python >= 2.7 and >= 3.2, the argparse module is maintained within the 43 | Python standard library. For users who still need to support Python < 2.7 or 44 | < 3.2, it is also provided as a separate package, which tries to stay 45 | compatible with the module in the standard library, but also supports older 46 | Python versions. 47 | 48 | argparse is licensed under the Python license, for details see LICENSE.txt. 49 | 50 | ######################################################################################## 51 | 52 | %prep 53 | %setup -qn %{pkg_name}-%{pkg_version} 54 | 55 | %clean 56 | rm -rf %{buildroot} 57 | 58 | %build 59 | python setup.py build 60 | 61 | %install 62 | rm -rf %{buildroot} 63 | python setup.py install --prefix=%{_prefix} \ 64 | --single-version-externally-managed -O1 \ 65 | --root=%{buildroot} 66 | 67 | ######################################################################################## 68 | 69 | %files 70 | %defattr(-,root,root,-) 71 | %doc LICENSE.txt NEWS.txt README.txt 72 | %{python_sitelib}/* 73 | 74 | ######################################################################################## 75 | 76 | %changelog 77 | * Sat Apr 29 2017 Yandex Team - 1.4.0-0 78 | - Initial build 79 | 80 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | 4 | # FileNotFoundError is not there in Python 2, define it: 5 | try: 6 | FileNotFoundError 7 | except NameError: 8 | FileNotFoundError = IOError 9 | 10 | with open("gixy/__init__.py", "r") as fd: 11 | version = re.search( 12 | r'^version\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE 13 | ).group(1) 14 | 15 | if not version: 16 | raise RuntimeError("Cannot find version information") 17 | 18 | install_requires = [ 19 | "pyparsing>=1.5.5,<=2.4.7", 20 | 'cached-property>=1.2.0;python_version<"3.8"', 21 | 'argparse>=1.4.0;python_version<"3.2"', 22 | "six>=1.1.0", 23 | "Jinja2>=2.8", 24 | "ConfigArgParse>=0.11.0", 25 | ] 26 | 27 | tests_requires = [ 28 | "pytest>=7.0.0", 29 | "pytest-xdist", 30 | ] 31 | 32 | # README.md is not present in Docker image setup 33 | long_description = None 34 | try: 35 | with open("README.md", "r", encoding="utf-8") as fh: 36 | long_description = fh.read() 37 | except FileNotFoundError: 38 | pass 39 | 40 | setup( 41 | name="gixy-ng", 42 | version=version, 43 | description="NGINX configuration [sec]analyzer", 44 | long_description=long_description, 45 | long_description_content_type="text/markdown", 46 | keywords="nginx security lint static-analysis", 47 | author="Yandex IS Team, GetPageSpeed LLC", 48 | author_email="buglloc@yandex.ru, info@getpagespeed.com", 49 | url="https://github.com/dvershinin/gixy", 50 | install_requires=install_requires, 51 | extras_require={ 52 | "tests": install_requires + tests_requires, 53 | }, 54 | entry_points={ 55 | "console_scripts": ["gixy=gixy.cli.main:main"], 56 | }, 57 | packages=find_packages(exclude=["tests", "tests.*"]), 58 | classifiers=[ 59 | "Development Status :: 3 - Alpha", 60 | "Environment :: Console", 61 | "Intended Audience :: System Administrators", 62 | "Intended Audience :: Developers", 63 | "Topic :: Security", 64 | "Topic :: Software Development :: Quality Assurance", 65 | "Topic :: Software Development :: Testing", 66 | "Programming Language :: Python :: 3.6", 67 | "Programming Language :: Python :: 3.7", 68 | "Programming Language :: Python :: 3.8", 69 | "Programming Language :: Python :: 3.9", 70 | "Programming Language :: Python :: 3.10", 71 | "Programming Language :: Python :: 3.11", 72 | "Programming Language :: Python :: 3.12", 73 | "Programming Language :: Python :: 3.13", 74 | ], 75 | include_package_data=True, 76 | ) 77 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/__init__.py -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/cli/__init__.py -------------------------------------------------------------------------------- /tests/cli/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module: test_cli.py 3 | 4 | This module demonstrates how to test the pixy's CLI using pytest. 5 | """ 6 | 7 | import sys 8 | import pytest 9 | from gixy.cli.main import main 10 | 11 | 12 | def test_cli_help(monkeypatch, capsys): 13 | """ 14 | Test that running the CLI with --help displays usage information. 15 | """ 16 | # Set sys.argv to simulate "pixy --help" 17 | monkeypatch.setattr(sys, "argv", ["pixy", "--help"]) 18 | 19 | # If the CLI prints help and then exits, SystemExit is expected. 20 | with pytest.raises(SystemExit) as e: 21 | main() 22 | 23 | # Optionally check exit code (commonly 0 for --help) 24 | assert e.value.code == 0 25 | 26 | # Capture and check the output for expected help text. 27 | captured = capsys.readouterr() 28 | assert "usage:" in captured.out.lower() 29 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/test_context.py: -------------------------------------------------------------------------------- 1 | from gixy.core.context import get_context, pop_context, push_context, purge_context, CONTEXTS, Context 2 | from gixy.directives.block import Root 3 | from gixy.core.variable import Variable 4 | from gixy.core.regexp import Regexp 5 | 6 | 7 | def setup_function(): 8 | assert len(CONTEXTS) == 0 9 | 10 | 11 | def teardown_function(): 12 | purge_context() 13 | 14 | 15 | def test_push_pop_context(): 16 | root_a = Root() 17 | push_context(root_a) 18 | assert len(CONTEXTS) == 1 19 | root_b = Root() 20 | push_context(root_b) 21 | assert len(CONTEXTS) == 2 22 | 23 | poped = pop_context() 24 | assert len(CONTEXTS) == 1 25 | assert poped.block == root_b 26 | poped = pop_context() 27 | assert len(CONTEXTS) == 0 28 | assert poped.block == root_a 29 | 30 | 31 | def test_push_get_purge_context(): 32 | root = Root() 33 | push_context(root) 34 | assert len(CONTEXTS) == 1 35 | assert get_context().block == root 36 | root = Root() 37 | push_context(root) 38 | assert len(CONTEXTS) == 2 39 | assert get_context().block == root 40 | 41 | purge_context() 42 | assert len(CONTEXTS) == 0 43 | 44 | 45 | def test_add_variables(): 46 | context = push_context(Root()) 47 | assert len(context.variables['index']) == 0 48 | assert len(context.variables['name']) == 0 49 | 50 | one_str_var = Variable('1') 51 | context.add_var('1', one_str_var) 52 | one_int_var = Variable(1) 53 | context.add_var(1, one_int_var) 54 | some_var = Variable('some') 55 | context.add_var('some', some_var) 56 | 57 | assert len(context.variables['index']) == 1 58 | assert context.variables['index'][1] == one_int_var 59 | assert len(context.variables['name']) == 1 60 | assert context.variables['name']['some'] == some_var 61 | context.clear_index_vars() 62 | assert len(context.variables['index']) == 0 63 | assert len(context.variables['name']) == 1 64 | assert context.variables['name']['some'] == some_var 65 | 66 | 67 | def test_get_variables(): 68 | context = push_context(Root()) 69 | assert len(context.variables['index']) == 0 70 | assert len(context.variables['name']) == 0 71 | 72 | one_var = Variable(1) 73 | context.add_var(1, one_var) 74 | some_var = Variable('some') 75 | context.add_var('some', some_var) 76 | 77 | assert context.get_var(1) == one_var 78 | assert context.get_var('some') == some_var 79 | # Checks not existed variables, for now context may return None 80 | assert context.get_var(0) == None 81 | assert context.get_var('not_existed') == None 82 | # Checks builtins variables 83 | assert context.get_var('uri') 84 | assert context.get_var('document_uri') 85 | assert context.get_var('arg_asdsadasd') 86 | assert context.get_var('args') 87 | 88 | 89 | def test_context_depend_variables(): 90 | push_context(Root()) 91 | assert len(get_context().variables['index']) == 0 92 | assert len(get_context().variables['name']) == 0 93 | 94 | get_context().add_var(1, Variable(1, value='one')) 95 | get_context().add_var('some', Variable('some', value='some')) 96 | 97 | assert get_context().get_var(1).value == 'one' 98 | assert get_context().get_var('some').value == 'some' 99 | 100 | # Checks top context variables are still exists 101 | push_context(Root()) 102 | assert get_context().get_var(1).value == 'one' 103 | assert get_context().get_var('some').value == 'some' 104 | 105 | # Checks variable overriding 106 | get_context().add_var('some', Variable('some', value='some_new')) 107 | get_context().add_var('foo', Variable('foo', value='foo')) 108 | assert get_context().get_var('some').value != 'some' 109 | assert get_context().get_var('some').value == 'some_new' 110 | assert get_context().get_var('foo').value == 'foo' 111 | assert get_context().get_var(1).value == 'one' 112 | 113 | # Checks variables after restore previous context 114 | pop_context() 115 | assert get_context().get_var('some').value != 'some_new' 116 | assert get_context().get_var('some').value == 'some' 117 | assert get_context().get_var('foo') == None 118 | assert get_context().get_var(1).value == 'one' 119 | 120 | 121 | def test_push_failed_with_regexp_py35_gixy_10(): 122 | push_context(Root()) 123 | assert len(get_context().variables['index']) == 0 124 | assert len(get_context().variables['name']) == 0 125 | 126 | regexp = Regexp('^/some/(.*?)') 127 | for name, group in regexp.groups.items(): 128 | get_context().add_var(name, Variable(name=name, value=group)) 129 | 130 | push_context(Root()) 131 | -------------------------------------------------------------------------------- /tests/core/test_variable.py: -------------------------------------------------------------------------------- 1 | from gixy.core.context import get_context, push_context, purge_context 2 | from gixy.directives.block import Root 3 | from gixy.core.regexp import Regexp 4 | from gixy.core.variable import Variable 5 | 6 | def setup_function(): 7 | push_context(Root()) 8 | 9 | 10 | def teardown_function(): 11 | purge_context() 12 | 13 | 14 | def test_literal(): 15 | var = Variable(name='simple', value='$uri', have_script=False) 16 | assert not var.depends 17 | assert not var.regexp 18 | assert var.value == '$uri' 19 | 20 | assert not var.can_startswith('$') 21 | assert not var.can_contain('i') 22 | assert var.must_contain('$') 23 | assert var.must_contain('u') 24 | assert not var.must_contain('a') 25 | assert var.must_startswith('$') 26 | assert not var.must_startswith('u') 27 | 28 | 29 | def test_regexp(): 30 | var = Variable(name='simple', value=Regexp('^/.*')) 31 | assert not var.depends 32 | assert var.regexp 33 | 34 | assert var.can_startswith('/') 35 | assert not var.can_startswith('a') 36 | assert var.can_contain('a') 37 | assert not var.can_contain('\n') 38 | assert var.must_contain('/') 39 | assert not var.must_contain('a') 40 | assert var.must_startswith('/') 41 | assert not var.must_startswith('a') 42 | 43 | 44 | def test_script(): 45 | get_context().add_var('foo', Variable(name='foo', value=Regexp('.*'))) 46 | var = Variable(name='simple', value='/$foo') 47 | assert var.depends 48 | assert not var.regexp 49 | 50 | assert not var.can_startswith('/') 51 | assert not var.can_startswith('a') 52 | assert var.can_contain('/') 53 | assert var.can_contain('a') 54 | assert not var.can_contain('\n') 55 | assert var.must_contain('/') 56 | assert not var.must_contain('a') 57 | assert var.must_startswith('/') 58 | assert not var.must_startswith('a') 59 | 60 | 61 | def test_regexp_boundary(): 62 | var = Variable(name='simple', value=Regexp('.*'), boundary=Regexp('/[a-z]', strict=True)) 63 | assert not var.depends 64 | assert var.regexp 65 | 66 | assert var.can_startswith('/') 67 | assert not var.can_startswith('a') 68 | assert not var.can_contain('/') 69 | assert var.can_contain('a') 70 | assert not var.can_contain('0') 71 | assert not var.can_contain('\n') 72 | assert var.must_contain('/') 73 | assert not var.must_contain('a') 74 | assert var.must_startswith('/') 75 | assert not var.must_startswith('a') 76 | 77 | 78 | def test_script_boundary(): 79 | get_context().add_var('foo', Variable(name='foo', value=Regexp('.*'), boundary=Regexp('[a-z]', strict=True))) 80 | var = Variable(name='simple', value='/$foo', boundary=Regexp('[/a-z0-9]', strict=True)) 81 | assert var.depends 82 | assert not var.regexp 83 | 84 | assert not var.can_startswith('/') 85 | assert not var.can_startswith('a') 86 | assert not var.can_contain('/') 87 | assert var.can_contain('a') 88 | assert not var.can_contain('\n') 89 | assert not var.can_contain('0') 90 | assert var.must_contain('/') 91 | assert not var.must_contain('a') 92 | assert var.must_startswith('/') 93 | assert not var.must_startswith('a') 94 | -------------------------------------------------------------------------------- /tests/directives/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/directives/__init__.py -------------------------------------------------------------------------------- /tests/directives/test_block.py: -------------------------------------------------------------------------------- 1 | from gixy.parser.nginx_parser import NginxParser 2 | from gixy.directives.block import * 3 | 4 | # TODO(buglloc): what about include block? 5 | 6 | 7 | def _get_parsed(config): 8 | root = NginxParser(cwd='', allow_includes=False).parse(config) 9 | return root.children[0] 10 | 11 | 12 | def test_block(): 13 | config = 'some {some;}' 14 | 15 | directive = _get_parsed(config) 16 | assert isinstance(directive, Block) 17 | assert directive.is_block 18 | assert directive.self_context 19 | assert not directive.provide_variables 20 | 21 | 22 | def test_http(): 23 | config = ''' 24 | http { 25 | default_type application/octet-stream; 26 | sendfile on; 27 | keepalive_timeout 65; 28 | } 29 | ''' 30 | 31 | directive = _get_parsed(config) 32 | assert isinstance(directive, HttpBlock) 33 | assert directive.is_block 34 | assert directive.self_context 35 | assert not directive.provide_variables 36 | 37 | 38 | def test_server(): 39 | config = ''' 40 | server { 41 | listen 80; 42 | server_name _; 43 | server_name cool.io; 44 | } 45 | 46 | ''' 47 | 48 | directive = _get_parsed(config) 49 | assert isinstance(directive, ServerBlock) 50 | assert directive.is_block 51 | assert directive.self_context 52 | assert [d.args[0] for d in directive.get_names()] == ['_', 'cool.io'] 53 | assert not directive.provide_variables 54 | 55 | 56 | def test_location(): 57 | config = ''' 58 | location / { 59 | } 60 | ''' 61 | 62 | directive = _get_parsed(config) 63 | assert isinstance(directive, LocationBlock) 64 | assert directive.is_block 65 | assert directive.self_context 66 | assert directive.provide_variables 67 | assert directive.modifier is None 68 | assert directive.path == '/' 69 | assert not directive.is_internal 70 | 71 | 72 | def test_location_internal(): 73 | config = ''' 74 | location / { 75 | internal; 76 | } 77 | ''' 78 | 79 | directive = _get_parsed(config) 80 | assert isinstance(directive, LocationBlock) 81 | assert directive.is_internal 82 | 83 | 84 | def test_location_modifier(): 85 | config = ''' 86 | location = / { 87 | } 88 | ''' 89 | 90 | directive = _get_parsed(config) 91 | assert isinstance(directive, LocationBlock) 92 | assert directive.modifier == '=' 93 | assert directive.path == '/' 94 | 95 | 96 | def test_if(): 97 | config = ''' 98 | if ($some) { 99 | } 100 | ''' 101 | 102 | directive = _get_parsed(config) 103 | assert isinstance(directive, IfBlock) 104 | assert directive.is_block 105 | assert not directive.self_context 106 | assert not directive.provide_variables 107 | assert directive.variable == '$some' 108 | assert directive.operand is None 109 | assert directive.value is None 110 | 111 | 112 | def test_if_modifier(): 113 | config = ''' 114 | if (-f /some) { 115 | } 116 | ''' 117 | 118 | directive = _get_parsed(config) 119 | assert isinstance(directive, IfBlock) 120 | assert directive.operand == '-f' 121 | assert directive.value == '/some' 122 | assert directive.variable is None 123 | 124 | 125 | def test_if_variable(): 126 | config = ''' 127 | if ($http_some = '/some') { 128 | } 129 | ''' 130 | 131 | directive = _get_parsed(config) 132 | assert isinstance(directive, IfBlock) 133 | assert directive.variable == '$http_some' 134 | assert directive.operand == '=' 135 | assert directive.value == '/some' 136 | 137 | 138 | def test_block_some_flat(): 139 | config = ''' 140 | some { 141 | default_type application/octet-stream; 142 | sendfile on; 143 | if (-f /some/) { 144 | keepalive_timeout 65; 145 | } 146 | } 147 | ''' 148 | 149 | directive = _get_parsed(config) 150 | for d in ['default_type', 'sendfile', 'keepalive_timeout']: 151 | c = directive.some(d, flat=True) 152 | assert c is not None 153 | assert c.name == d 154 | 155 | 156 | def test_block_some_not_flat(): 157 | config = ''' 158 | some { 159 | default_type application/octet-stream; 160 | sendfile on; 161 | if (-f /some/) { 162 | keepalive_timeout 65; 163 | } 164 | } 165 | ''' 166 | 167 | directive = _get_parsed(config) 168 | c = directive.some('keepalive_timeout', flat=False) 169 | assert c is None 170 | 171 | 172 | def test_block_find_flat(): 173 | config = ''' 174 | some { 175 | directive 1; 176 | if (-f /some/) { 177 | directive 2; 178 | } 179 | } 180 | ''' 181 | 182 | directive = _get_parsed(config) 183 | finds = directive.find('directive', flat=True) 184 | assert len(finds) == 2 185 | assert [x.name for x in finds] == ['directive', 'directive'] 186 | assert [x.args[0] for x in finds] == ['1', '2'] 187 | 188 | 189 | def test_block_find_not_flat(): 190 | config = ''' 191 | some { 192 | directive 1; 193 | if (-f /some/) { 194 | directive 2; 195 | } 196 | } 197 | ''' 198 | 199 | directive = _get_parsed(config) 200 | finds = directive.find('directive', flat=False) 201 | assert len(finds) == 1 202 | assert [x.name for x in finds] == ['directive'] 203 | assert [x.args[0] for x in finds] == ['1'] 204 | 205 | 206 | def test_block_map(): 207 | config = ''' 208 | map $some_var $some_other_var { 209 | a b; 210 | default c; 211 | } 212 | ''' 213 | 214 | directive = _get_parsed(config) 215 | assert isinstance(directive, MapBlock) 216 | assert directive.is_block 217 | assert not directive.self_context 218 | assert directive.provide_variables 219 | assert directive.variable == 'some_other_var' 220 | 221 | 222 | def test_block_geo_two_vars(): 223 | config = ''' 224 | geo $some_var $some_other_var { 225 | 1.2.3.4 b; 226 | default c; 227 | } 228 | ''' 229 | 230 | directive = _get_parsed(config) 231 | assert isinstance(directive, GeoBlock) 232 | assert directive.is_block 233 | assert not directive.self_context 234 | assert directive.provide_variables 235 | assert directive.variable == 'some_other_var' 236 | 237 | 238 | def test_block_geo_one_var(): 239 | config = ''' 240 | geo $some_var { 241 | 5.6.7.8 d; 242 | default e; 243 | } 244 | ''' 245 | 246 | directive = _get_parsed(config) 247 | assert isinstance(directive, GeoBlock) 248 | assert directive.is_block 249 | assert not directive.self_context 250 | assert directive.provide_variables 251 | assert directive.variable == 'some_var' 252 | -------------------------------------------------------------------------------- /tests/directives/test_directive.py: -------------------------------------------------------------------------------- 1 | from gixy.parser.nginx_parser import NginxParser 2 | from gixy.directives.directive import * 3 | 4 | 5 | def _get_parsed(config): 6 | root = NginxParser(cwd='', allow_includes=False).parse(config) 7 | return root.children[0] 8 | 9 | 10 | def test_directive(): 11 | config = 'some "foo" "bar";' 12 | 13 | directive = _get_parsed(config) 14 | assert isinstance(directive, Directive) 15 | assert directive.name == 'some' 16 | assert directive.args == ['foo', 'bar'] 17 | assert str(directive) == 'some foo bar;' 18 | 19 | 20 | def test_add_header(): 21 | config = 'add_header "X-Foo" "bar";' 22 | 23 | directive = _get_parsed(config) 24 | assert isinstance(directive, AddHeaderDirective) 25 | assert directive.name == 'add_header' 26 | assert directive.args == ['X-Foo', 'bar'] 27 | assert directive.header == 'x-foo' 28 | assert directive.value == 'bar' 29 | assert not directive.always 30 | assert str(directive) == 'add_header X-Foo bar;' 31 | 32 | 33 | def test_add_header_always(): 34 | config = 'add_header "X-Foo" "bar" always;' 35 | 36 | directive = _get_parsed(config) 37 | assert isinstance(directive, AddHeaderDirective) 38 | assert directive.name == 'add_header' 39 | assert directive.args == ['X-Foo', 'bar', 'always'] 40 | assert directive.header == 'x-foo' 41 | assert directive.value == 'bar' 42 | assert directive.always 43 | assert str(directive) == 'add_header X-Foo bar always;' 44 | 45 | 46 | def test_set(): 47 | config = 'set $foo bar;' 48 | 49 | directive = _get_parsed(config) 50 | assert isinstance(directive, SetDirective) 51 | assert directive.name == 'set' 52 | assert directive.args == ['$foo', 'bar'] 53 | assert directive.variable == 'foo' 54 | assert directive.value == 'bar' 55 | assert str(directive) == 'set $foo bar;' 56 | assert directive.provide_variables 57 | 58 | 59 | def test_rewrite(): 60 | config = 'rewrite ^ http://some;' 61 | 62 | directive = _get_parsed(config) 63 | assert isinstance(directive, RewriteDirective) 64 | assert directive.name == 'rewrite' 65 | assert directive.args == ['^', 'http://some'] 66 | assert str(directive) == 'rewrite ^ http://some;' 67 | assert directive.provide_variables 68 | 69 | assert directive.pattern == '^' 70 | assert directive.replace == 'http://some' 71 | assert directive.flag == None 72 | 73 | 74 | def test_rewrite_flags(): 75 | config = 'rewrite ^/(.*)$ http://some/$1 redirect;' 76 | 77 | directive = _get_parsed(config) 78 | assert isinstance(directive, RewriteDirective) 79 | assert directive.name == 'rewrite' 80 | assert directive.args == ['^/(.*)$', 'http://some/$1', 'redirect'] 81 | assert str(directive) == 'rewrite ^/(.*)$ http://some/$1 redirect;' 82 | assert directive.provide_variables 83 | 84 | assert directive.pattern == '^/(.*)$' 85 | assert directive.replace == 'http://some/$1' 86 | assert directive.flag == 'redirect' 87 | 88 | 89 | def test_root(): 90 | config = 'root /var/www/html;' 91 | 92 | directive = _get_parsed(config) 93 | assert isinstance(directive, RootDirective) 94 | assert directive.name == 'root' 95 | assert directive.args == ['/var/www/html'] 96 | assert str(directive) == 'root /var/www/html;' 97 | assert directive.provide_variables 98 | 99 | assert directive.path == '/var/www/html' 100 | -------------------------------------------------------------------------------- /tests/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/parser/__init__.py -------------------------------------------------------------------------------- /tests/parser/test_nginx_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from gixy.parser.nginx_parser import NginxParser 3 | from gixy.directives.directive import * 4 | from gixy.directives.block import * 5 | 6 | 7 | def _parse(config): 8 | return NginxParser(cwd='', allow_includes=False).parse(config) 9 | 10 | 11 | @pytest.mark.parametrize('config,expected', zip( 12 | [ 13 | 'access_log syslog:server=127.0.0.1,tag=nginx_sentry toolsformat;', 14 | 'user http;', 15 | 'internal;', 16 | 'set $foo "bar";', 17 | "set $foo 'bar';", 18 | 'proxy_pass http://unix:/run/sock.socket;', 19 | 'rewrite ^/([a-zA-Z0-9]+)$ /$1/${arg_v}.pb break;' 20 | ], 21 | 22 | [ 23 | [Directive], 24 | [Directive], 25 | [Directive], 26 | [Directive, SetDirective], 27 | [Directive, SetDirective], 28 | [Directive], 29 | [Directive, RewriteDirective] 30 | ] 31 | )) 32 | def test_directive(config, expected): 33 | assert_config(config, expected) 34 | 35 | 36 | @pytest.mark.parametrize('config,expected', zip( 37 | [ 38 | 'if (-f /some) {}', 39 | 'location / {}' 40 | ], 41 | 42 | [ 43 | [Directive, Block, IfBlock], 44 | [Directive, Block, LocationBlock], 45 | ] 46 | )) 47 | def test_blocks(config, expected): 48 | assert_config(config, expected) 49 | 50 | 51 | def test_dump_simple(): 52 | config = ''' 53 | # configuration file /etc/nginx/nginx.conf: 54 | http { 55 | include sites/*.conf; 56 | } 57 | 58 | # configuration file /etc/nginx/conf.d/listen: 59 | listen 80; 60 | 61 | # configuration file /etc/nginx/sites/default.conf: 62 | server { 63 | include conf.d/listen; 64 | } 65 | ''' 66 | 67 | tree = _parse(config) 68 | assert isinstance(tree, Directive) 69 | assert isinstance(tree, Block) 70 | assert isinstance(tree, Root) 71 | 72 | assert len(tree.children) == 1 73 | http = tree.children[0] 74 | assert isinstance(http, Directive) 75 | assert isinstance(http, Block) 76 | assert isinstance(http, HttpBlock) 77 | 78 | assert len(http.children) == 1 79 | include_server = http.children[0] 80 | assert isinstance(include_server, Directive) 81 | assert isinstance(include_server, IncludeBlock) 82 | assert include_server.file_path == '/etc/nginx/sites/default.conf' 83 | 84 | assert len(include_server.children) == 1 85 | server = include_server.children[0] 86 | assert isinstance(server, Directive) 87 | assert isinstance(server, Block) 88 | assert isinstance(server, ServerBlock) 89 | 90 | assert len(server.children) == 1 91 | include_listen = server.children[0] 92 | assert isinstance(include_listen, Directive) 93 | assert isinstance(include_listen, IncludeBlock) 94 | assert include_listen.file_path == '/etc/nginx/conf.d/listen' 95 | 96 | assert len(include_listen.children) == 1 97 | listen = include_listen.children[0] 98 | assert isinstance(listen, Directive) 99 | assert listen.args == ['80'] 100 | 101 | 102 | def test_encoding(): 103 | configs = [ 104 | 'bar "\xD1\x82\xD0\xB5\xD1\x81\xD1\x82";' 105 | ] 106 | 107 | for i, config in enumerate(configs): 108 | _parse(config) 109 | 110 | 111 | def assert_config(config, expected): 112 | tree = _parse(config) 113 | assert isinstance(tree, Directive) 114 | assert isinstance(tree, Block) 115 | assert isinstance(tree, Root) 116 | 117 | child = tree.children[0] 118 | for ex in expected: 119 | assert isinstance(child, ex) 120 | -------------------------------------------------------------------------------- /tests/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dvershinin/gixy/2978e6359a75e39a8a1eea160ccb699e3e15d2db/tests/plugins/__init__.py -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_content_type/add_header_content_type.conf: -------------------------------------------------------------------------------- 1 | add_header Content-Type text/plain; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_content_type/add_header_content_type_fp.conf: -------------------------------------------------------------------------------- 1 | add_header Something-Else text/plain; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/add_header.conf: -------------------------------------------------------------------------------- 1 | add_header Content-Security-Policy " 2 | default-src: 'none'; 3 | font-src data: https://yastatic.net;"; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/add_header_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Foo foo; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "LOW" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: Bar 3 | multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: Bar multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_multiple.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html text/plain' 2 | 'X-Foo: some 3 | multiline' 4 | 'X-Bar: some 5 | multiline' 6 | 'X-Baz: some 7 | multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_replace.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -r 'Foo: 2 | multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_replace_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -r 'Foo: multiline'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_status_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -s 404 -s '500 503' 'Foo: bar'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_multiline/more_set_headers_type_fp.conf: -------------------------------------------------------------------------------- 1 | more_set_headers -t 'text/html 2 | text/plain' 'X-Foo: some'; -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": ["LOW", "MEDIUM"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/duplicate_fp.conf: -------------------------------------------------------------------------------- 1 | http { 2 | add_header X-Frame-Options "DENY" always; 3 | server { 4 | location /new-headers { 5 | add_header X-Frame-Options "DENY" always; 6 | add_header X-Foo foo; 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/if_replaces.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | 3 | if (1) { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/location_replaces.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | 3 | location /new-headers { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/nested_block.conf: -------------------------------------------------------------------------------- 1 | server { 2 | add_header X-Frame-Options "DENY" always; 3 | location / { 4 | location /some { 5 | add_header X-Frame-Options "DENY" always; 6 | } 7 | 8 | location /another { 9 | add_header X-Foo foo; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/non_block_fp.conf: -------------------------------------------------------------------------------- 1 | add_header X-Frame-Options "DENY" always; 2 | server "some"; 3 | add_header X-Foo foo; 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/not_secure_dropped.conf: -------------------------------------------------------------------------------- 1 | add_header X-Bar bar; 2 | 3 | location /new-headers { 4 | add_header X-Foo foo; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/not_secure_outer.conf: -------------------------------------------------------------------------------- 1 | add_header X-Bar bar; 2 | 3 | location /new-headers { 4 | add_header X-Frame-Options "DENY" always; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/add_header_redefinition/step_replaces.conf: -------------------------------------------------------------------------------- 1 | http { 2 | add_header X-Frame-Options "DENY" always; 3 | server { 4 | location /new-headers { 5 | add_header X-Foo foo; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": ["MEDIUM", "HIGH"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/nested.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | location /files/images { 3 | alias /home/; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/nested_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | location /files/images { 3 | } 4 | alias /home/; 5 | } 6 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/not_slashed_alias.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/not_slashed_alias_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/simple.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/simple_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/slashed_alias.conf: -------------------------------------------------------------------------------- 1 | location /files { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/alias_traversal/slashed_alias_fp.conf: -------------------------------------------------------------------------------- 1 | location /files/ { 2 | alias /home/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/allow_without_deny/allow_without_deny.conf: -------------------------------------------------------------------------------- 1 | location = /test/ { 2 | allow 1.1.1.1; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/allow_without_deny/allow_without_deny_fp.conf: -------------------------------------------------------------------------------- 1 | location = /test/ { 2 | allow 1.1.1.1; 3 | deny all; 4 | } 5 | -------------------------------------------------------------------------------- /tests/plugins/simply/error_log_off/error_log_off.conf: -------------------------------------------------------------------------------- 1 | error_log off; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/error_log_off/error_log_off_fp.conf: -------------------------------------------------------------------------------- 1 | error_log /path/to/error.log; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "MEDIUM" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_fp.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Host $host; -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_host.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header Host $http_host; -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/http_host_diff_case.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header HoSt $http_host; -------------------------------------------------------------------------------- /tests/plugins/simply/host_spoofing/some_arg.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header host $arg_host; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/add_header_uri.conf: -------------------------------------------------------------------------------- 1 | add_header X-Uri $uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/dont_report_not_resolved_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | proxy_pass http://storage/$some; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | proxy_pass http://storage/$2; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | set $p $2; 3 | proxy_pass http://storage/$p; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(\W*)$ { 2 | set $p $1; 3 | proxy_pass http://storage/$p; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_from_location_var_var_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(a|b)/(?

\W*)$ { 2 | set $upstream "http://$1/$p?"; 3 | proxy_pass $upstream; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_cr_fp.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/test/(.*) { 2 | proxy_pass http://10.10.10.10/$1; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_ducument_uri.conf: -------------------------------------------------------------------------------- 1 | proxy_pass http://upstream$document_uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_pass_lf.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/test/([^/]+)/ { 2 | proxy_pass http://10.10.10.10/$1; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/proxy_set_header_ducument_uri.conf: -------------------------------------------------------------------------------- 1 | proxy_set_header "X-Original-Uri" $document_uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/return_403_fp.conf: -------------------------------------------------------------------------------- 1 | return 403; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/return_request_uri_fp.conf: -------------------------------------------------------------------------------- 1 | return 301 https://some$request_uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_extract_fp.conf: -------------------------------------------------------------------------------- 1 | rewrite ^/proxy/(a|b)/(?\W*)$ http://storage/$path redirect; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_uri.conf: -------------------------------------------------------------------------------- 1 | rewrite ^ http://some$uri; -------------------------------------------------------------------------------- /tests/plugins/simply/http_splitting/rewrite_uri_after_var.conf: -------------------------------------------------------------------------------- 1 | return 301 https://$host$uri; -------------------------------------------------------------------------------- /tests/plugins/simply/if_is_evil/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/if_is_evil/if_is_evil_add_header.conf: -------------------------------------------------------------------------------- 1 | location /only-one-if { 2 | set $true 1; 3 | 4 | if ($true) { 5 | add_header X-First 1; 6 | } 7 | 8 | if ($true) { 9 | add_header X-Second 2; 10 | } 11 | 12 | return 204; 13 | } 14 | -------------------------------------------------------------------------------- /tests/plugins/simply/if_is_evil/if_is_evil_fp.conf: -------------------------------------------------------------------------------- 1 | location /only-one-if { 2 | set $true 1; 3 | 4 | if ($true) { 5 | return 403; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/plugins/simply/origins/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": ["MEDIUM", "HIGH"] 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/metrika.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https?://([^/]+metrika.*yandex\.(ru|ua|com|com\.tr|by|kz)|([^/]+\.)?webvisor\.com)/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_https.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"], "https_only": true} 2 | 3 | if ($http_origin !~ '^https?:\/\/yandex\.ru\/') { 4 | 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_https_fp.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"], "https_only": true} 2 | 3 | if ($http_origin !~ '^https:\/\/yandex\.ru\/') { 4 | 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_w_slash_anchored_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru/$') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_w_slash_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_origin !~ '^https?:\/\/yandex\.ru/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/origin_wo_slash.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["yandex.ru"]} 2 | 3 | http { 4 | if ($http_origin !~ '^https?:\/\/yandex\.ru') { 5 | 6 | } 7 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/yandex.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_subdomain.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/some.yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/referer_subdomain_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ '^https?:\/\/some\.yandex\.ru\/') { 2 | 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_dot.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_fp.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example\.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_prefix.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "https://example\.com/"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/structure_suffix.conf: -------------------------------------------------------------------------------- 1 | if ($http_referer !~ "^https://example\.com"){ 2 | add_header X-Frame-Options SAMEORIGIN; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/origins/webvisor.conf: -------------------------------------------------------------------------------- 1 | # Options: {"domains": ["webvisor.com", "yandex.com"]} 2 | 3 | if ($http_referer !~ "^https?://([^/]+\.)?yandex\.com/|([^/]+\.)?webvisor\.com/"){ 4 | add_header X-Frame-Options SAMEORIGIN; 5 | } -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/missing_variable.conf: -------------------------------------------------------------------------------- 1 | location /b { 2 | rewrite ^ $request_uri; # Sets the $1/$uri variable to the raw path 3 | proxy_pass http://127.0.0.1:8000/; # No $1 or $uri or other variable, resulting in path being double-encoded 4 | } 5 | 6 | # Request received by nginx: /%2F 7 | # Request received by backend: /%252F 8 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/missing_variable_fp.conf: -------------------------------------------------------------------------------- 1 | location /b { 2 | rewrite ^ $request_uri; # Sets the $1/$uri variable to the raw path 3 | proxy_pass http://127.0.0.1:8000/$1; # $1 used, resulting in path being passed as the raw path from the original request. 4 | } 5 | 6 | # Request received by nginx: /%2F 7 | # Request received by backend: /%2F # Possibly also receives //%2F) 8 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/missing_variable_nopath.conf: -------------------------------------------------------------------------------- 1 | location /b { 2 | rewrite ^ $request_uri; # Sets the $1/$uri variable to the raw path 3 | proxy_pass http://127.0.0.1:8000; # No $1 or $uri or other variable in either host or path, resulting in path being double-encoded 4 | } 5 | 6 | # Request received by nginx: /%2F 7 | # Request received by backend: /%252F 8 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/missing_variable_nopath_fp.conf: -------------------------------------------------------------------------------- 1 | location /b { 2 | rewrite ^ $request_uri; # Sets the $1/$uri variable to the raw path 3 | proxy_pass http://127.0.0.1:8000$1; # $1 used in host, resulting in path being passed as the raw path from the original request. 4 | } 5 | 6 | # Request received by nginx: /%2F 7 | # Request received by backend: /%2F # Possibly also receives //%2F) 8 | # This is actually no different than proxy_pass_path_fp.conf since $1 is not a path. 9 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/proxy_pass_path.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | proxy_pass http://server/; # Path "/" used, resulting in proxy_pass urldecoding the path. 3 | } 4 | 5 | # Request received by nginx: /%2F 6 | # Request received by backend: // 7 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/proxy_pass_path_fp.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | proxy_pass http://downstream; # No rewrite rules, and no path used: no extra/missing urldecoding/urlencoding occurs. 3 | } 4 | 5 | # Request received by nginx: /%2F 6 | # Request received by backend: /%2F 7 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/proxy_pass_socket_fp.conf: -------------------------------------------------------------------------------- 1 | # Proxy pass to socket file path, without path specifier should not trigger test failure 2 | server { 3 | server_name my.server.name; 4 | 5 | location / { 6 | proxy_pass http://unix:/run/sockets/my-server.sock; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/proxy_pass_socket_with_path.conf: -------------------------------------------------------------------------------- 1 | # Proxy pass to socket file path, without path specifier should not trigger test failure 2 | server { 3 | server_name my.server.name; 4 | 5 | location / { 6 | proxy_pass http://unix:/run/sockets/my-server.sock:/test/; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/proxy_pass_var_fp.conf: -------------------------------------------------------------------------------- 1 | # False positive because we can't reliably check whether variable has path or not 2 | location @__auth__event_proxying { 3 | proxy_pass $sm_auth_event_url; 4 | } 5 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/rewrite_with_return_fp.conf: -------------------------------------------------------------------------------- 1 | location /1/ { 2 | rewrite ^ $request_uri; # Sets $1/$uri to raw path. 3 | rewrite ^/1(/.*) $1 break; # Extracts everything after /1 and places it into $1/$uri. 4 | return 400; # extremely important! # If rewrite rule does not break (e.g. //1/), return 400, otherwise the location-block will fall-through and proxy_pass will not actually happen at all. 5 | proxy_pass http://127.0.0.1:8080/$1; # $1 used, resulting in path being passed as the raw path from the original request. 6 | } 7 | 8 | # Request received by nginx: /1/%2F 9 | # Request received by backend: /%2F (or possibly //%2F) 10 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/variable.conf: -------------------------------------------------------------------------------- 1 | location @__auth__event_proxying { 2 | proxy_pass $sm_auth_event_url/; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/proxy_pass_normalized/variable_fp.conf: -------------------------------------------------------------------------------- 1 | location @__auth__event_proxying { 2 | proxy_pass $sm_auth_event_url; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/resolver_external/resolver_external.conf: -------------------------------------------------------------------------------- 1 | resolver 1.1.1.1; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/resolver_external/resolver_external_fp.conf: -------------------------------------------------------------------------------- 1 | resolver 127.0.0.1; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/resolver_external/resolver_local_fp.conf: -------------------------------------------------------------------------------- 1 | resolver 10.0.0.1; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/resolver_external/resolver_local_ipv6_fp.conf: -------------------------------------------------------------------------------- 1 | resolver 127.0.0.1 ::1; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/resolver_external/resolver_local_ipv6_with_port_fp.conf: -------------------------------------------------------------------------------- 1 | resolver [::1]:53; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/rewrite_with_return.conf: -------------------------------------------------------------------------------- 1 | location /1/ { 2 | rewrite ^ $request_uri; # Sets $1/$uri to raw path 3 | rewrite ^/1(/.*) $1 break; # Extracts everything after /1 4 | return 400; # extremely important! # If rewrite rule does not break (e.g. //1/), return 400, otherwise the location-block will fall-through. 5 | proxy_pass http://127.0.0.1:8080; # No $1 or $uri or other variable, resulting in path being double-encoded 6 | } 7 | 8 | # Request received by nginx: /1/%2F 9 | # Request received by backend: /%252F (or possibly //%252F) 10 | -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/have_internal_fp.conf: -------------------------------------------------------------------------------- 1 | location /proxy/ { 2 | internal; 3 | proxy_pass $arg_some; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/host_w_const_start.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/backend/(?.*) { 2 | proxy_pass http://some$path; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/host_w_const_start_arg.conf: -------------------------------------------------------------------------------- 1 | location /backend/ { 2 | proxy_pass http://some${arg_la}.shit; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/not_host_var_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(.*)$ { 2 | proxy_pass http://yastatic.net/$1; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/request_uri_fp.conf: -------------------------------------------------------------------------------- 1 | location /backend/ { 2 | proxy_pass http://some$request_uri; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/request_uri_var_fp.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | set $upstream "http://some$request_uri"; 3 | proxy_pass $upstream; 4 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/scheme_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/$ { 2 | proxy_pass $http_proxy_scheme://some/file.conf; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/single_var.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(?P.*)$ { 2 | proxy_pass $proxy; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/used_arg.conf: -------------------------------------------------------------------------------- 1 | location /proxy/ { 2 | proxy_pass $arg_some; 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/vars_from_loc.conf: -------------------------------------------------------------------------------- 1 | location ~ /proxy/(.*)/(.*)/(.*)$ { 2 | set $scheme $1; 3 | set $host $2; 4 | set $path $3; 5 | proxy_pass $scheme://$host/$path; 6 | } -------------------------------------------------------------------------------- /tests/plugins/simply/ssrf/with_const_scheme.conf: -------------------------------------------------------------------------------- 1 | location ~* ^/internal-proxy/(https?)/(.*?)/(.*) { 2 | resolver 127.0.0.1; 3 | 4 | set $proxy_protocol $1; 5 | set $proxy_host $2; 6 | set $proxy_path $3; 7 | 8 | proxy_pass $proxy_protocol://$proxy_host/$proxy_path ; 9 | proxy_set_header Host $proxy_host; 10 | } -------------------------------------------------------------------------------- /tests/plugins/simply/try_files_is_evil_too/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "MEDIUM" 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/try_files_is_evil_too/try_files_is_evil_too.conf: -------------------------------------------------------------------------------- 1 | open_file_cache off; 2 | location / { 3 | location /test/ { 4 | try_files $uri $uri/ /index.php&is_args$args; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/plugins/simply/try_files_is_evil_too/try_files_is_evil_too_cache_none.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | try_files $uri $uri/ /index.php&is_args$args; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/try_files_is_evil_too/try_files_is_evil_too_fp.conf: -------------------------------------------------------------------------------- 1 | open_file_cache max=1000 inactive=20s; 2 | location / { 3 | try_files $uri $uri/ /index.php&is_args$args; 4 | } 5 | -------------------------------------------------------------------------------- /tests/plugins/simply/unanchored_regex/unanchored_regex.conf: -------------------------------------------------------------------------------- 1 | location ~ \.php { 2 | fastcgi_pass /path/to/some.sock; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/unanchored_regex/unanchored_regex_fp.conf: -------------------------------------------------------------------------------- 1 | location ~ \.php$ { 2 | fastcgi_pass /path/to/some.sock; 3 | } 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "severity": "HIGH" 3 | } -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_first.conf: -------------------------------------------------------------------------------- 1 | valid_referers none server_names *.webvisor.com; -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_last.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names 2 | foo.com 3 | none; -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/none_middle.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names foo.com 2 | none bar.com; -------------------------------------------------------------------------------- /tests/plugins/simply/valid_referers/wo_none_fp.conf: -------------------------------------------------------------------------------- 1 | valid_referers server_names foo.com bar.com *.none.com none.ru; -------------------------------------------------------------------------------- /tests/plugins/simply/version_disclosure/server_tokens_off_fp.conf: -------------------------------------------------------------------------------- 1 | server_tokens off; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/version_disclosure/server_tokens_on.conf: -------------------------------------------------------------------------------- 1 | server_tokens on; 2 | -------------------------------------------------------------------------------- /tests/plugins/simply/worker_rlimit_nofile_vs_connections/worker_rlimit_nofile_vs_connections_fp.conf: -------------------------------------------------------------------------------- 1 | # Will not be triggerered because worker_rlimit_nofile is higher than worker_connections 2x 2 | worker_connections 1024; 3 | worker_rlimit_nofile 2048; 4 | -------------------------------------------------------------------------------- /tests/plugins/simply/worker_rlimit_nofile_vs_connections/worker_rlimit_nofile_vs_connections_missing.conf: -------------------------------------------------------------------------------- 1 | # Will be triggerered because worker_rlimit_nofile is missing 2 | worker_connections 1024; 3 | -------------------------------------------------------------------------------- /tests/plugins/simply/worker_rlimit_nofile_vs_connections/worker_rlimit_nofile_vs_connections_too_low.conf: -------------------------------------------------------------------------------- 1 | # Will be triggerered because worker_rlimit_nofile is too low 2 | worker_connections 1024; 3 | worker_rlimit_nofile 1024; 4 | -------------------------------------------------------------------------------- /tests/plugins/test_simply.py: -------------------------------------------------------------------------------- 1 | from gixy.formatters import BaseFormatter 2 | import os 3 | from os import path 4 | import json 5 | import pytest 6 | 7 | from ..utils import * 8 | from gixy.core.manager import Manager as Gixy 9 | from gixy.core.plugins_manager import PluginsManager 10 | from gixy.core.config import Config 11 | 12 | 13 | def generate_config_test_cases(): 14 | tested_plugins = set() 15 | tested_fp_plugins = set() 16 | 17 | config_cases = [] 18 | config_fp_cases = [] 19 | 20 | conf_dir = path.join(path.dirname(__file__), 'simply') 21 | for plugin in os.listdir(conf_dir): 22 | if plugin in ('.', '..'): 23 | continue 24 | 25 | plugin_path = path.join(conf_dir, plugin) 26 | if not path.isdir(plugin_path): 27 | continue 28 | 29 | config = {} 30 | if path.exists(path.join(plugin_path, 'config.json')): 31 | with open(path.join(plugin_path, 'config.json'), 'r') as file: 32 | config = json.loads(file.read()) 33 | 34 | for test_case in os.listdir(plugin_path): 35 | if not test_case.endswith('.conf'): 36 | continue 37 | 38 | config_path = path.join(plugin_path, test_case) 39 | if not test_case.endswith('_fp.conf'): 40 | # Not False Positive test 41 | tested_plugins.add(plugin) 42 | config_cases.append((plugin, config_path, config)) 43 | else: 44 | tested_fp_plugins.add(plugin) 45 | config_fp_cases.append((plugin, config_path, config)) 46 | 47 | manager = PluginsManager() 48 | for plugin in manager.plugins: 49 | if getattr(plugin, 'skip_test', False): 50 | continue 51 | plugin = plugin.name 52 | assert plugin in tested_plugins, 'Plugin {name!r} should have at least one simple test config'.format(name=plugin) 53 | assert plugin in tested_fp_plugins, 'Plugin {name!r} should have at least one simple test config with false positive'.format(name=plugin) 54 | 55 | return config_cases, config_fp_cases 56 | 57 | 58 | all_config_cases, all_config_fp_cases = generate_config_test_cases() 59 | 60 | 61 | def parse_plugin_options(config_path): 62 | with open(config_path, 'r') as f: 63 | config_line = f.readline() 64 | if config_line.startswith('# Options: '): 65 | return json.loads(config_line[10:]) 66 | return None 67 | 68 | 69 | def yoda_provider(plugin, plugin_options=None): 70 | config = Config( 71 | allow_includes=False, 72 | plugins=[plugin] 73 | ) 74 | if plugin_options: 75 | config.set_for(plugin, plugin_options) 76 | return Gixy(config=config) 77 | 78 | 79 | @pytest.mark.parametrize('plugin,config_path,test_config', all_config_cases) 80 | def test_configuration(plugin, config_path, test_config): 81 | plugin_options = parse_plugin_options(config_path) 82 | with yoda_provider(plugin, plugin_options) as yoda: 83 | yoda.audit(config_path, open(config_path, mode='r')) 84 | formatter = BaseFormatter() 85 | formatter.feed(config_path, yoda) 86 | _, results = formatter.reports.popitem() 87 | 88 | assert len(results) == 1, 'Should have one report' 89 | result = results[0] 90 | 91 | if 'severity' in test_config: 92 | if not hasattr(test_config['severity'], '__iter__'): 93 | assert result['severity'] == test_config['severity'] 94 | else: 95 | assert result['severity'] in test_config['severity'] 96 | assert result['plugin'] == plugin 97 | assert result['summary'] 98 | assert result['description'] 99 | assert result['config'] 100 | assert result['help_url'].startswith('https://'), 'help_url must starts with https://. It\'is URL!' 101 | 102 | 103 | @pytest.mark.parametrize('plugin,config_path,test_config', all_config_fp_cases) 104 | def test_configuration_fp(plugin, config_path, test_config): 105 | with yoda_provider(plugin) as yoda: 106 | yoda.audit(config_path, open(config_path, mode='r')) 107 | assert len([x for x in yoda.results]) == 0, 'False positive configuration must not trigger any plugins' 108 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from logging.handlers import BufferingHandler 2 | 3 | 4 | class LogHandler(BufferingHandler): 5 | def __init__(self, matcher): 6 | # BufferingHandler takes a "capacity" argument 7 | # so as to know when to flush. As we're overriding 8 | # shouldFlush anyway, we can set a capacity of zero. 9 | # You can call flush() manually to clear out the 10 | # buffer. 11 | super(LogHandler, self).__init__(0) 12 | self.matcher = matcher 13 | 14 | def shouldFlush(self, **kwargs): 15 | return False 16 | 17 | def emit(self, record): 18 | self.buffer.append(record.__dict__) 19 | 20 | def matches(self, **kwargs): 21 | """ 22 | Look for a saved dict whose keys/values match the supplied arguments. 23 | """ 24 | result = False 25 | for d in self.buffer: 26 | if self.matcher.matches(d, **kwargs): 27 | result = True 28 | break 29 | return result 30 | 31 | 32 | class Matcher(object): 33 | 34 | _partial_matches = ('msg', 'message') 35 | 36 | def matches(self, d, **kwargs): 37 | """ 38 | Try to match a single dict with the supplied arguments. 39 | 40 | Keys whose values are strings and which are in self._partial_matches 41 | will be checked for partial (i.e. substring) matches. You can extend 42 | this scheme to (for example) do regular expression matching, etc. 43 | """ 44 | result = True 45 | for k in kwargs: 46 | v = kwargs[k] 47 | dv = d.get(k) 48 | if not self.match_value(k, dv, v): 49 | result = False 50 | break 51 | return result 52 | 53 | def match_value(self, k, dv, v): 54 | """ 55 | Try to match a single stored value (dv) with a supplied value (v). 56 | """ 57 | if type(v) != type(dv): 58 | result = False 59 | elif type(dv) is not str or k not in self._partial_matches: 60 | result = (v == dv) 61 | else: 62 | result = dv.find(v) >= 0 63 | return result 64 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, py39, py310, py311, py312, py313, flake8 3 | skip_missing_interpreters = True 4 | 5 | [testenv] 6 | deps = 7 | -rrequirements.txt 8 | -rrequirements.dev.txt 9 | commands = pytest -v 10 | 11 | [testenv:flake8] 12 | deps = 13 | flake8 14 | basepython = python3 15 | commands = 16 | flake8 setup.py gixy 17 | 18 | [flake8] 19 | max_line_length = 120 20 | --------------------------------------------------------------------------------