Changeset - 5b102c4afcb3
[Not reviewed]
0 10 0
Branko Majic (branko) - 4 years ago 2020-11-16 22:15:35
branko@majic.rs
MAR-170: Always enforce use of HTTPS in the php_server role:

- Dropped the enforce_https parameter.
- Updated tests.
- Updated release notes.
- Update role reference documentation.
- Update usage instructions.
10 files changed with 35 insertions and 55 deletions:
0 comments (0 inline, 0 general)
docs/releasenotes.rst
Show inline comments
 
@@ -51,24 +51,29 @@ Breaking changes:
 

	
 
* ``mail_server`` role
 

	
 
  * Use 2048-bit Diffie-Hellman parameters for relevant TLS
 
    ciphers. This could introduce incompatibility with older
 
    clients/servers trying to connect to the SMTP/IMAP server.
 

	
 
  * Updated default set of TLS ciphers used by IMAP/SMTP servers
 
    (``mail_server_tls_ciphers`` parameter). All CBC ciphers have been
 
    dropped. This could introduce incompatibility with older clients
 
    trying to connect to the IMAP/SMTP server.
 

	
 
* ``php_website`` role
 

	
 
  * Parameter ``enforce_https`` has been deprecated and
 
    removed. HTTPS is now mandatory in all cases.
 

	
 
* ``preseed`` role
 

	
 
  * Parameter ``ansible_key`` is now mandatory.
 

	
 
  * Parameter ``preseed_directory`` is now mandatory.
 

	
 
* ``web_server`` role
 

	
 
  * Use 2048-bit Diffie-Hellman parameters for relevant TLS
 
    ciphers. This could introduce incompatibility with older clients
 
    trying to connect to the web server.
 

	
docs/rolereference.rst
Show inline comments
 
@@ -1465,24 +1465,30 @@ The role implements the following:
 
* Creates a dedicated administrator user for maintaining the website.
 
* Creates a base directory where the website-specific code and data should be
 
  stored at.
 
* Adds nginx to website's group, so nginx could read the necessary files.
 
* Adds website administrator to website's group, so administrator could manage
 
  the code and data.
 
* Installs additional packages required for running the role (as configured).
 
* Deploys the HTTPS TLS private key and certificate (for website vhost).
 
* Configures PHP FPM and nginx to serve the website.
 

	
 
The role is implemented with the following layout/logic in mind:
 

	
 
* Clients are served with ``Strict-Transport-Security`` header with
 
  value of ``max-age=31536000; includeSubDomains``. This forces
 
  compliant clients to always connect using HTTPS to the web server
 
  when accessing its domain, as well as any subdomains served
 
  by this web server or any other. The (client-side) cached header
 
  value expires after one year.
 
* Website users are named after the ``FQDN`` (fully qualified domain name) of
 
  website, in format of ``web-ESCAPEDFQDN``, where ``ESCAPEDFQDN`` is equal to
 
  ``FQDN`` where dots have been replaced by underscores (for example,
 
  ``web-cloud_example_com``).
 
* Website users are set-up via GECOS field to have their umask set to ``0007``
 
  (in combination with ``pam_umask``).
 
* Administrator users are named after the ``FQDN`` (fully qualified domain name)
 
  of website, in format of ``admin-ESCAPEDFQDN``, where ``ESCAPEDFQDN`` is equal
 
  to ``FQDN`` where dots have been replaced by underscores (for example,
 
  ``admin-cloud_example_com``).
 
* All websites reside within a dedicated sub-directory in ``/var/www``. The
 
  sub-directory name is equal to the ``FQDN`` used for accessing the
 
@@ -1532,30 +1538,24 @@ Parameters
 

	
 
**admin_uid** (integer, optional, ``whatever OS picks``)
 
  UID of the dedicated website administrator user. The user will be member of
 
  website group.
 

	
 
**deny_files_regex** (list, optional, ``[]``)
 
  List of regular expressions for matching files/locations to which the web
 
  server should deny access. This is useful to block access to any sensitive
 
  files that should not be served directly by the web server. The format must be
 
  compatible with regular expressions used by ``nginx`` for ``location ~``
 
  syntax.
 

	
 
**enforce_https** (boolean, optional, ``True``)
 
  Specify if HTTPS should be enforced for the website or not. If enforced,
 
  clients connecting via plaintext will be redirected to HTTPS, and clients will
 
  be served with ``Strict-Transport-Security`` header with value of
 
  ``max-age=31536000; includeSubDomains``.
 

	
 
**environment_indicator** (dictionary, optional, ``null``)
 
  Specify configuration for including environment indicator on all HTML
 
  pages. Indicator is a simple strip at bottom of a page with custom background
 
  colour, text colour, and text.
 

	
 
  Specifying environment indicator is useful for avoiding mistakes when testing
 
  by having better visibility what environment you are in
 
  (production/staging/test).
 

	
 
  The following keys need to be specified:
 

	
 
  **background_colour** (string, mandatory)
docs/usage.rst
Show inline comments
 
@@ -1228,26 +1228,25 @@ Let us first define what we want to deploy on the web server. Here is the plan:
 
   2. `Django Wiki <https://github.com/django-wiki/django-wiki>`_ - a wiki
 
      application written in Django. This will serve as a demo of how the WSGI
 
      role works.
 

	
 
It should be noted that the web application deployment roles are a bit more
 
complex - namely they are not meant to be used directly, but instead as a
 
dependency for a custom role. They do come with decent amount of batteries
 
included, and also play nice with the web server role.
 

	
 
As mentioned before, all roles will enforce TLS by default. The web server roles
 
will additionaly implement HSTS policy by sending connecting clients
 
``Strict-Transport-Security`` header with value set to ``max-age=31536000;
 
includeSubDomains`` (if you disable enforcement of TLS, the header will not be
 
sent).
 
includeSubDomains``.
 

	
 
With all the above noted, let us finally move on to the next step.
 

	
 

	
 
Setting-up the web server
 
-------------------------
 

	
 
Finally we are moving on to the web server deployment, and we shell start
 
with... Well, erm, web server deployment! To be more precise, we will set-up
 
Nginx.
 

	
 
1. Update the playbook for web server to include the web server role.
roles/php_website/defaults/main.yml
Show inline comments
 
---
 

	
 
additional_nginx_config: {}
 
deny_files_regex: []
 
enforce_https: true
 
index: index.php
 
packages: []
 
php_file_regex: \.php$
 
php_rewrite_urls: []
 
rewrites: []
 
additional_fpm_config: {}
 
website_mail_recipients: "root"
 
environment_indicator: null
 

	
 
# Internal parameters.
 
admin: "admin-{{ fqdn | replace('.', '_') }}"
 
user: "web-{{ fqdn | replace('.', '_') }}"
roles/php_website/molecule/default/playbook.yml
Show inline comments
 
@@ -18,25 +18,24 @@
 
      https_tls_key: "{{ lookup('file', 'tests/data/x509/server/parameters-mandatory_https.key.pem') }}"
 

	
 
    - role: php_website
 
      additional_fpm_config:
 
        "env[PATH]": "\"/usr/local/bin:/usr/bin:/bin\""
 
        "security.limit_extensions": ".php .myphp"
 
      additional_nginx_config:
 
        - comment: Custom missing page.
 
          value: error_page 404 /404.myphp;
 
      admin_uid: 5000
 
      deny_files_regex:
 
        - '^/secretfile.txt'
 
      enforce_https: false
 
      environment_indicator:
 
        background_colour: "#ff0000"
 
        text_colour: "#00ff00"
 
        text: "parameters-optional"
 
      fqdn: parameters-optional.local
 
      index: myindex.php
 
      https_tls_certificate: "{{ lookup('file', 'tests/data/x509/server/parameters-optional_https.cert.pem') }}"
 
      https_tls_key: "{{ lookup('file', 'tests/data/x509/server/parameters-optional_https.key.pem') }}"
 
      php_file_regex: "\\.myphp$"
 
      php_rewrite_urls:
 
        - ^/rewrite1/(.*)$ /rewrite.myphp?url=$1 last
 
        - ^/rewrite2/(.*)$ /rewrite.myphp?url=$1 last
roles/php_website/molecule/default/tests/test_default.py
Show inline comments
 
import os
 

	
 
import pytest
 

	
 
import testinfra.utils.ansible_runner
 

	
 

	
 
testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
 
    os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all')
 

	
 

	
 
@pytest.mark.parametrize('fqdn', [
 
    'parameters-mandatory',
 
    'parameters-optional.local',
 
])
 
def test_https_enforcement(host, fqdn):
 
    """
 
    Tests if HTTPS is being enforced.
 
    """
 

	
 
    https_enforcement = host.run('curl -I http://%s/', fqdn)
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'HTTP/1.1 301 Moved Permanently' in https_enforcement.stdout
 
    assert 'Location: https://%s/' % fqdn in https_enforcement.stdout
 

	
 
    https_enforcement = host.run('curl -I https://%s/', fqdn)
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'Strict-Transport-Security: max-age=31536000; includeSubDomains' in https_enforcement.stdout
roles/php_website/molecule/default/tests/test_parameters_mandatory.py
Show inline comments
 
@@ -199,38 +199,21 @@ def test_vhost_file(host):
 

	
 
def test_website_enabled(host):
 
    """
 
    Tests if website has been enabled.
 
    """
 

	
 
    config = host.file('/etc/nginx/sites-enabled/parameters-mandatory')
 

	
 
    assert config.is_symlink
 
    assert config.linked_to == '/etc/nginx/sites-available/parameters-mandatory'
 

	
 

	
 
def test_https_enforcement(host):
 
    """
 
    Tests if HTTPS is being enforced.
 
    """
 

	
 
    https_enforcement = host.run('curl -I http://parameters-mandatory/')
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'HTTP/1.1 301 Moved Permanently' in https_enforcement.stdout
 
    assert 'Location: https://parameters-mandatory/' in https_enforcement.stdout
 

	
 
    https_enforcement = host.run('curl -I https://parameters-mandatory/')
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'Strict-Transport-Security: max-age=31536000; includeSubDomains' in https_enforcement.stdout
 

	
 

	
 
def test_index_page(host):
 
    """
 
    Tests if index page is served correctly.
 
    """
 

	
 
    page = host.run('curl https://parameters-mandatory/')
 

	
 
    assert page.rc == 0
 
    assert page.stdout == "This is the index page for parameters-mandatory."
roles/php_website/molecule/default/tests/test_parameters_optional.py
Show inline comments
 
@@ -193,42 +193,24 @@ def test_vhost_file(host):
 

	
 
def test_website_enabled(host):
 
    """
 
    Tests if website has been enabled.
 
    """
 

	
 
    config = host.file('/etc/nginx/sites-enabled/parameters-optional.local')
 

	
 
    assert config.is_symlink
 
    assert config.linked_to == '/etc/nginx/sites-available/parameters-optional.local'
 

	
 

	
 
def test_https_enforcement(host):
 
    """
 
    Tests if HTTPS is (not) being enforced.
 
    """
 

	
 
    https_enforcement = host.run('curl -I http://parameters-optional.local/')
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'HTTP/1.1 200 OK' in https_enforcement.stdout
 
    assert 'HTTP/1.1 301 Moved Permanently' not in https_enforcement.stdout
 
    assert 'Location: https://parameters-optional/' not in https_enforcement.stdout
 

	
 
    https_enforcement = host.run('curl -I https://parameters-optional.local/')
 

	
 
    assert https_enforcement.rc == 0
 
    assert 'Strict-Transport-Security' not in https_enforcement.stdout
 

	
 

	
 
def test_index_page(host):
 
    """
 
    Tests if index page is served correctly (should be php file served statically).
 
    """
 

	
 
    page = host.run('curl https://parameters-optional.local/')
 

	
 
    assert page.rc == 0
 
    assert page.stdout == open("tests/data/php/optional/myindex.php").read().rstrip()
 

	
 

	
 
def test_additional_fpm_config(host):
roles/php_website/templates/nginx_site.j2
Show inline comments
 
{% if enforce_https -%}
 
server {
 
    # HTTP (plaintext) configuration.
 
    listen 80;
 
    server_name {{ fqdn }};
 

	
 
    # Redirect plaintext connections to HTTPS
 
    return 301 https://$host$request_uri;
 
}
 

	
 
{% endif -%}
 
server {
 
    # Base settings.
 
    root {{ home }}/htdocs/;
 
    index {{ index }};
 
    server_name {{ fqdn }};
 
{% if not enforce_https %}
 

	
 
    # HTTP (plaintext) configuration.
 
    listen 80;
 

	
 
{% endif %}
 
    # HTTPS (TLS) configuration.
 
    listen 443 ssl;
 
    listen [::]:443 ssl;
 
    ssl_certificate_key /etc/ssl/private/{{ fqdn }}_https.key;
 
    ssl_certificate /etc/ssl/certs/{{ fqdn }}_https.pem;
 

	
 
{% if enforce_https -%}
 
    # Set-up HSTS header for preventing downgrades for users that visited the
 
    # site via HTTPS at least once.
 
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
 
{% endif -%}
 

	
 
    {% for config in additional_nginx_config -%}
 
    # {{ config.comment }}
 
    {{ config.value }}
 
    {% endfor -%}
 

	
 
    {% if rewrites -%}
 
    # Generic URL rewrites.
 
    {% for rewrite in rewrites -%}
 
    rewrite {{ rewrite }};
 
    {% endfor -%}
 
    {% endif %}
testsite/playbooks/roles/phpinfo/meta/main.yml
Show inline comments
 
---
 

	
 
dependencies:
 
  - role: php_website
 
    fqdn: phpinfo.{{ testsite_domain }}
 
    php_rewrite_urls:
 
      - ^(.*) /index.php
 
    admin_uid: 3000
 
    uid: 2000
 
    enforce_https: false
 
    https_tls_key: "{{ lookup('file', inventory_dir + '/tls/phpinfo.' + testsite_domain + '_https.key') }}"
 
    https_tls_certificate: "{{ lookup('file', inventory_dir + '/tls/phpinfo.' + testsite_domain + '_https.pem') }}"
 
    additional_fpm_config:
 
      "env[PATH]": "\"/usr/local/bin:/usr/bin:/bin\""
 
  - role: database
 
    db_name: phpinfo_{{ testsite_domain_underscores }}
 
    db_password: phpinfo_{{ testsite_domain_underscores }}
0 comments (0 inline, 0 general)