www/nginx: Rework security header forms and adding CSP frame-ancestors and HSTS preload directives (#2702)

The security header edit dialog contains many fields and a tabbed
interface for the dialog itself allows better configuration of the headers.
This commit is contained in:
Manuel Faux
2022-01-12 11:24:08 +01:00
committed by GitHub
parent 1a86d52df8
commit 5a61822b0d
9 changed files with 1016 additions and 580 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
PLUGIN_NAME= nginx PLUGIN_NAME= nginx
PLUGIN_VERSION= 1.25 PLUGIN_VERSION= 1.26
PLUGIN_COMMENT= Nginx HTTP server and reverse proxy PLUGIN_COMMENT= Nginx HTTP server and reverse proxy
PLUGIN_DEPENDS= nginx PLUGIN_DEPENDS= nginx
PLUGIN_MAINTAINER= franz.fabian.94@gmail.com PLUGIN_MAINTAINER= franz.fabian.94@gmail.com
+2 -2
View File
@@ -33,11 +33,11 @@ Most are standard but some endpoints support maps, which are not
supported by OPNsense core. supported by OPNsense core.
You can detect them simply as they are doing more than just a mapping You can detect them simply as they are doing more than just a mapping
to the *base methods. to the \*base methods.
Such mappings work in the way that they catch up the request, Such mappings work in the way that they catch up the request,
map the internal data first, and then forward their UUIDs map the internal data first, and then forward their UUIDs
to the *base method. to the \*base method.
## The nginx plugin as infrastucture ## The nginx plugin as infrastucture
+7
View File
@@ -10,6 +10,13 @@ WWW: https://nginx.org/
Plugin Changelog Plugin Changelog
================ ================
1.26
* Enhancement of security headers (contributed by Manuel Faux)
Add Frame-Ancestors, add "preload", removed deprecated HPKP
* Performance enhancements for log display
* Fixed display of vts and logs for non-default styles
1.25 1.25
* Reworked logging frontent to support filtering and historic view (contributed by Manuel Faux) * Reworked logging frontent to support filtering and historic view (contributed by Manuel Faux)
@@ -342,7 +342,59 @@ class SettingsController extends ApiMutableModelControllerBase
// http security headers // http security headers
public function searchsecurityHeaderAction() public function searchsecurityHeaderAction()
{ {
return $this->searchBase('security_header', array('description')); $data = $this->searchBase('security_header',
['description', 'referrer', 'xssprotection', 'strict_transport_security_time',
'enable_csp', 'csp_report_only', 'csp_default_src_enabled', 'csp_script_src_enabled', 'csp_img_src_enabled',
'csp_style_src_enabled', 'csp_media_src_enabled', 'csp_font_src_enabled', 'csp_frame_src_enabled',
'csp_frame_ancestors_enabled', 'csp_form_action_enabled']);
// Create "hsts" column (disabled/time)
foreach ($data['rows'] as &$row) {
if (intval($row['strict_transport_security_time']) > 0) {
$row['hsts'] = sprintf(gettext("%d sec"), intval($row['strict_transport_security_time']));
} else {
$row['hsts'] = gettext('disabled');
}
}
// Create "csp" column (enabled/report only/disabled)
foreach ($data['rows'] as &$row) {
if ($row['enable_csp']) {
if ($row['csp_report_only']) {
$row['csp'] = gettext('report only');
} else {
$row['csp'] = gettext('enabled');
}
} else {
$row['csp'] = gettext('disabled');
}
}
// Create "csp_details" column
foreach ($data['rows'] as &$row) {
if ($row['enable_csp']) {
$enabled = [];
if ($row['csp_default_src_enabled']) $enabled[] = gettext("Default Source");
if ($row['csp_script_src_enabled']) $enabled[] = gettext("Script Source");
if ($row['csp_img_src_enabled']) $enabled[] = gettext("Image Source");
if ($row['csp_style_src_enabled']) $enabled[] = gettext("Style Source");
if ($row['csp_media_src_enabled']) $enabled[] = gettext("Media Source");
if ($row['csp_font_src_enabled']) $enabled[] = gettext("Font Source");
if ($row['csp_frame_src_enabled']) $enabled[] = gettext("Frame Source");
if ($row['csp_frame_ancestors_enabled']) $enabled[] = gettext("Frame Ancestors");
if ($row['csp_form_action_enabled']) $enabled[] = gettext("Form Action");
if (count($enabled)) {
$row['csp_details'] = implode(', ', $enabled);
} else {
$row['csp_details'] = gettext('none');
}
} else {
$row['csp_details'] = '';
}
}
return $data;
} }
public function getsecurityHeaderAction($uuid = null) public function getsecurityHeaderAction($uuid = null)
File diff suppressed because it is too large Load Diff
@@ -1194,19 +1194,10 @@
<Required>Y</Required> <Required>Y</Required>
<default>1</default> <default>1</default>
</strict_transport_security_include_subdomains> </strict_transport_security_include_subdomains>
<hpkp_keys type="CSVListField"> <strict_transport_security_preload type="BooleanField">
<Required>N</Required>
<mask>/[a-z0-9\+\/=]+(,[a-z0-9\+\/=]+)*/i</mask>
</hpkp_keys>
<hpkp_report_only type="BooleanField">
<Required>Y</Required> <Required>Y</Required>
</hpkp_report_only> <default>0</default>
<hpkp_time type="IntegerField"> </strict_transport_security_preload>
<Required>N</Required>
</hpkp_time>
<hpkp_include_subdomains type="BooleanField">
<Required>Y</Required>
</hpkp_include_subdomains>
<enable_csp type="BooleanField"> <enable_csp type="BooleanField">
<Required>Y</Required> <Required>Y</Required>
</enable_csp> </enable_csp>
@@ -1448,6 +1439,76 @@
<Required>Y</Required> <Required>Y</Required>
<default>0</default> <default>0</default>
</csp_font_src_none> </csp_font_src_none>
<csp_frame_src_enabled type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_enabled>
<csp_frame_src_data_urls type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_data_urls>
<csp_frame_src_http_urls type="CSVListField">
<Required>N</Required>
</csp_frame_src_http_urls>
<csp_frame_src_inline type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_inline>
<csp_frame_src_eval type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_eval>
<csp_frame_src_self type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_self>
<csp_frame_src_blob type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_blob>
<csp_frame_src_mediastream type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_mediastream>
<csp_frame_src_filesystem type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_filesystem>
<csp_frame_src_none type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_src_none>
<csp_frame_ancestors_enabled type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_enabled>
<csp_frame_ancestors_data_urls type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_data_urls>
<csp_frame_ancestors_http_urls type="CSVListField">
<Required>N</Required>
</csp_frame_ancestors_http_urls>
<csp_frame_ancestors_self type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_self>
<csp_frame_ancestors_blob type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_blob>
<csp_frame_ancestors_mediastream type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_mediastream>
<csp_frame_ancestors_filesystem type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_filesystem>
<csp_frame_ancestors_none type="BooleanField">
<Required>Y</Required>
<default>0</default>
</csp_frame_ancestors_none>
<csp_form_action_enabled type="BooleanField"> <csp_form_action_enabled type="BooleanField">
<Required>Y</Required> <Required>Y</Required>
<default>0</default> <default>0</default>
@@ -478,6 +478,11 @@
<thead> <thead>
<tr> <tr>
<th data-column-id="description" data-type="string" data-sortable="true" data-visible="true">{{ lang._('Description') }}</th> <th data-column-id="description" data-type="string" data-sortable="true" data-visible="true">{{ lang._('Description') }}</th>
<th data-column-id="referrer" data-type="string" data-sortable="true" data-visible="false">{{ lang._('Referrer') }}</th>
<th data-column-id="xssprotection" data-type="string" data-sortable="true" data-visible="true">{{ lang._('XSS Protection') }}</th>
<th data-column-id="hsts" data-type="string" data-sortable="true" data-visible="true">{{ lang._('HSTS') }}</th>
<th data-column-id="csp" data-type="string" data-sortable="true" data-visible="true">{{ lang._('CSP') }}</th>
<th data-column-id="csp_details" data-type="string" data-sortable="true" data-visible="false">{{ lang._('CSP Rules') }}</th>
<th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th> <th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr> </tr>
</thead> </thead>
@@ -691,7 +696,7 @@
{{ partial("layout_partials/base_dialog",['fields': httprewrite,'id':'httprewritedlg', 'label':lang._('Edit URL Rewrite')]) }} {{ partial("layout_partials/base_dialog",['fields': httprewrite,'id':'httprewritedlg', 'label':lang._('Edit URL Rewrite')]) }}
{{ partial("layout_partials/base_dialog",['fields': naxsi_custom_policy,'id':'custompolicydlg', 'label':lang._('Edit WAF Policy')]) }} {{ partial("layout_partials/base_dialog",['fields': naxsi_custom_policy,'id':'custompolicydlg', 'label':lang._('Edit WAF Policy')]) }}
{{ partial("layout_partials/base_dialog",['fields': naxsi_rule,'id':'naxsiruledlg', 'label':lang._('Edit Naxsi Rule')]) }} {{ partial("layout_partials/base_dialog",['fields': naxsi_rule,'id':'naxsiruledlg', 'label':lang._('Edit Naxsi Rule')]) }}
{{ partial("layout_partials/base_dialog",['fields': security_headers,'id':'security_headersdlg', 'label':lang._('Edit Security Headers')]) }} {{ partial("OPNsense/Nginx/tabbed_dialog",['fields': security_headers,'id':'security_headersdlg', 'label':lang._('Edit Security Headers')]) }}
{{ partial("layout_partials/base_dialog",['fields': limit_request_connection,'id':'limit_request_connectiondlg', 'label':lang._('Edit Request Connection Limit')]) }} {{ partial("layout_partials/base_dialog",['fields': limit_request_connection,'id':'limit_request_connectiondlg', 'label':lang._('Edit Request Connection Limit')]) }}
{{ partial("layout_partials/base_dialog",['fields': limit_zone,'id':'limit_zonedlg', 'label':lang._('Edit Limit Zone')]) }} {{ partial("layout_partials/base_dialog",['fields': limit_zone,'id':'limit_zonedlg', 'label':lang._('Edit Limit Zone')]) }}
{{ partial("layout_partials/base_dialog",['fields': cache_path,'id':'cache_pathdlg', 'label':lang._('Edit Cache Path')]) }} {{ partial("layout_partials/base_dialog",['fields': cache_path,'id':'cache_pathdlg', 'label':lang._('Edit Cache Path')]) }}
@@ -0,0 +1,183 @@
{#
# Copyright (c) 2021 Manuel Faux
# OPNsense® is Copyright © 2014-2021 by Deciso B.V.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
# AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
# OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#}
{#
# Generate input dialog, uses the following parameters (as associative array):
#
# fields : list of field type objects, see form_input_tr tag for details
# id : form id, used as unique id for this modal form. inner form to place data is called frm_[id]
# save button is identified by btn_[id]_save
# label : dialog label
#}
{# Volt templates in php7 have issues with scope sometimes, copy input values to make them more unique #}
{% set base_dialog_id=id %}
{% set base_dialog_fields=fields %}
{% set base_dialog_label=label %}
{# Find if there are help supported or advanced field on this page #}
{% set base_dialog_help=false %}
{% set base_dialog_advanced=false %}
{% for field in base_dialog_fields|default({})%}
{% for name,element in field %}
{% if name=='help' %}
{% set base_dialog_help=true %}
{% endif %}
{% if name=='advanced' %}
{% set base_dialog_advanced=true %}
{% endif %}
{% endfor %}
{% if base_dialog_help|default(false) and base_dialog_advanced|default(false) %}
{% break %}
{% endif %}
{% endfor %}
<script>
$(function() {
// hook into on-show event to extend validation
$('#{{base_dialog_id}}').on('shown.bs.modal', function (e) {
$('.nav-tabs a[href="#frm_{{base_dialog_id}}-tab_general"]').tab('show');
$("#btn_{{base_dialog_id}}_save").click(function() {
// TODO: Search for tab with validation errors, but currently only the first tab has even validation rules
$('.nav-tabs a[href="#frm_{{base_dialog_id}}-tab_general"]').tab('show');
});
})
// Read currently selected tab in main form and store for later
$('#{{base_dialog_id}}').on('show.bs.modal', function (e) {
$('#{{base_dialog_id}}').attr("data-inittab", window.location.hash);
});
// Restore selected tab of main form as URL was modified by dialog tabs
$('#{{base_dialog_id}}').on('hide.bs.modal', function (e) {
window.location.hash = $('#{{base_dialog_id}}').attr("data-inittab");
});
});
</script>
<div class="modal fade" id="{{base_dialog_id}}" tabindex="-1" role="dialog" aria-labelledby="{{base_dialog_id}}Label" aria-hidden="true">
<div class="modal-backdrop fade in"></div>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{ lang._('Close') }}"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="{{base_dialog_id}}Label">{{base_dialog_label}}</h4>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" role="tablist" id="dialogtabs">
{% for field in base_dialog_fields['tabs']|default({})%}
<li>
<a data-toggle="tab" href="#frm_{{base_dialog_id}}-tab_{{field[0]}}"><b>{{field[1]}}</b></a>
</li>
{% endfor %}
</ul>
<form id="frm_{{base_dialog_id}}">
<div class="content-box tab-content">
<div class="table-responsive">
<table class="table table-striped table-condensed">
<colgroup>
<col class="col-md-3"/>
<col class="col-md-{{ 12-3-msgzone_width|default(5) }}"/>
<col class="col-md-{{ msgzone_width|default(5) }}"/>
</colgroup>
<tbody>
{% if base_dialog_advanced|default(false) or base_dialog_help|default(false) %}
<tr>
<td>{% if base_dialog_advanced|default(false) %}<a href="#"><i class="fa fa-toggle-off text-danger" id="show_advanced_formDialog{{base_dialog_id}}"></i></a> <small>{{ lang._('advanced mode') }}</small>{% endif %}</td>
<td colspan="2" style="text-align:right;">
{% if base_dialog_help|default(false) %}<small>{{ lang._('full help') }}</small> <a href="#"><i class="fa fa-toggle-off text-danger" id="show_all_help_formDialog{{base_dialog_id}}"></i></a>{% endif %}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% for tab in base_dialog_fields['tabs']|default({})%}
<div id="frm_{{base_dialog_id}}-tab_{{tab[0]}}" class="tab-pane fade">
<div class="table-responsive">
<table class="table table-striped table-condensed">
<colgroup>
<col class="col-md-3"/>
<col class="col-md-{{ 12-3-msgzone_width|default(5) }}"/>
<col class="col-md-{{ msgzone_width|default(5) }}"/>
</colgroup>
<tbody>
{% for field in tab[2]|default({})%}
{# looks a bit buggy in the volt templates, field parameters won't reset properly here #}
{% set advanced=false %}
{% set help=false %}
{% set hint=false %}
{% set style=false %}
{% set maxheight=false %}
{% set width=false %}
{% set allownew=false %}
{% set readonly=false %}
{% if field['type'] == 'header' %}
</tbody>
</table>
</div>
<div class="table-responsive {{field['style']|default('')}}">
<table class="table table-striped table-condensed">
<colgroup>
<col class="col-md-3"/>
<col class="col-md-{{ 12-3-msgzone_width|default(5) }}"/>
<col class="col-md-{{ msgzone_width|default(5) }}"/>
</colgroup>
<thead>
<tr{% if field['advanced']|default(false)=='true' %} data-advanced="true"{% endif %}>
<th colspan="3">
<h2>{{field['label']}}</h2>
{%- if field['hint']|default(false) %}
<small>{{field['hint']}}</small>
{%- endif %}
</th>
</tr>
</thead>
<tbody>
{% else %}
{{ partial("layout_partials/form_input_tr",field)}}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
</form>
</div>
<div class="modal-footer">
{% if hasSaveBtn|default('true') == 'true' %}
<button type="button" class="btn btn-default" data-dismiss="modal">{{ lang._('Cancel') }}</button>
<button type="button" class="btn btn-primary" id="btn_{{base_dialog_id}}_save">{{ lang._('Save') }} <i id="btn_{{base_dialog_id}}_save_progress" class=""></i></button>
{% else %}
<button type="button" class="btn btn-default" data-dismiss="modal">{{ lang._('Close') }}</button>
{% endif %}
</div>
</div>
</div>
</div>
@@ -1,3 +1,4 @@
# security rules
{% if security_rule.referrer is defined %} {% if security_rule.referrer is defined %}
{% do our_headers.append('Referrer-Policy') %} {% do our_headers.append('Referrer-Policy') %}
add_header Referrer-Policy "{{ security_rule.referrer }}" always; add_header Referrer-Policy "{{ security_rule.referrer }}" always;
@@ -13,21 +14,12 @@
{% if security_rule.strict_transport_security_time is defined %} {% if security_rule.strict_transport_security_time is defined %}
{% do our_headers.append('Strict-Transport-Security') %} {% do our_headers.append('Strict-Transport-Security') %}
add_header Strict-Transport-Security "max-age={{ security_rule.strict_transport_security_time }}{% add_header Strict-Transport-Security "max-age={{ security_rule.strict_transport_security_time }}{%
if security_rule.strict_transport_security_include_subdomains is defined and if security_rule.strict_transport_security_include_subdomains|default('0') == '1' %}; includeSubDomains{% endif %}{%
security_rule.strict_transport_security_include_subdomains == '1' %}; includeSubDomains{% endif %}" always; if security_rule.strict_transport_security_preload|default('0') == '1' %}; preload{% endif %}" always;
{% endif %}
{% if security_rule.hpkp_keys is defined and security_rule.hpkp_time is defined %}
{% do our_headers.append('Public-Key-Pins') %}
{% do our_headers.append('Public-Key-Pins-Report-Only') %}
add_header Public-Key-Pins{% if security_rule.hpkp_report_only is defined and security_rule.hpkp_report_only == '1'
%}-Report-Only{% endif %} "{% for key in security_rule.hpkp_keys.split(',')
%}pin-sha256={{ key }}; {% endfor %}max-age={{ security_rule.hpkp_time }}{%
if security_rule.hpkp_include_subdomains is defined and
security_rule.hpkp_include_subdomains == '1' %}; includeSubDomains{% endif %}" always;
{% endif %} {% endif %}
{% if security_rule.enable_csp is defined and security_rule.enable_csp == '1' %} {% if security_rule.enable_csp is defined and security_rule.enable_csp == '1' %}
{% set hash_csp = {} %} {% set hash_csp = {} %}
{% for csp_category in ['default-src', 'script-src', 'img-src', 'style-src', 'media-src', 'font-src', 'form-action'] %} {% for csp_category in ['default-src', 'script-src', 'img-src', 'style-src', 'media-src', 'font-src', 'frame-src', 'frame-ancestors', 'form-action'] %}
{% set prefix = 'csp_' + csp_category.replace('-', '_') + '_' %} {% set prefix = 'csp_' + csp_category.replace('-', '_') + '_' %}
{% if security_rule[prefix + 'enabled'] == '1' %} {% if security_rule[prefix + 'enabled'] == '1' %}
{% set current_list = [] %} {% set current_list = [] %}