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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1016 additions and 580 deletions

View file

@ -1,5 +1,5 @@
PLUGIN_NAME= nginx
PLUGIN_VERSION= 1.25
PLUGIN_VERSION= 1.26
PLUGIN_COMMENT= Nginx HTTP server and reverse proxy
PLUGIN_DEPENDS= nginx
PLUGIN_MAINTAINER= franz.fabian.94@gmail.com

View file

@ -33,11 +33,11 @@ Most are standard but some endpoints support maps, which are not
supported by OPNsense core.
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,
map the internal data first, and then forward their UUIDs
to the *base method.
to the \*base method.
## The nginx plugin as infrastucture

View file

@ -10,6 +10,13 @@ WWW: https://nginx.org/
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
* Reworked logging frontent to support filtering and historic view (contributed by Manuel Faux)

View file

@ -342,7 +342,59 @@ class SettingsController extends ApiMutableModelControllerBase
// http security headers
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)

View file

@ -1194,19 +1194,10 @@
<Required>Y</Required>
<default>1</default>
</strict_transport_security_include_subdomains>
<hpkp_keys type="CSVListField">
<Required>N</Required>
<mask>/[a-z0-9\+\/=]+(,[a-z0-9\+\/=]+)*/i</mask>
</hpkp_keys>
<hpkp_report_only type="BooleanField">
<strict_transport_security_preload type="BooleanField">
<Required>Y</Required>
</hpkp_report_only>
<hpkp_time type="IntegerField">
<Required>N</Required>
</hpkp_time>
<hpkp_include_subdomains type="BooleanField">
<Required>Y</Required>
</hpkp_include_subdomains>
<default>0</default>
</strict_transport_security_preload>
<enable_csp type="BooleanField">
<Required>Y</Required>
</enable_csp>
@ -1448,6 +1439,76 @@
<Required>Y</Required>
<default>0</default>
</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">
<Required>Y</Required>
<default>0</default>

View file

@ -478,6 +478,11 @@
<thead>
<tr>
<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>
</tr>
</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': 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': 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_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')]) }}

View file

@ -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>

View file

@ -1,3 +1,4 @@
# security rules
{% if security_rule.referrer is defined %}
{% do our_headers.append('Referrer-Policy') %}
add_header Referrer-Policy "{{ security_rule.referrer }}" always;
@ -13,21 +14,12 @@
{% if security_rule.strict_transport_security_time is defined %}
{% do our_headers.append('Strict-Transport-Security') %}
add_header Strict-Transport-Security "max-age={{ security_rule.strict_transport_security_time }}{%
if security_rule.strict_transport_security_include_subdomains is defined and
security_rule.strict_transport_security_include_subdomains == '1' %}; includeSubDomains{% 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;
if security_rule.strict_transport_security_include_subdomains|default('0') == '1' %}; includeSubDomains{% endif %}{%
if security_rule.strict_transport_security_preload|default('0') == '1' %}; preload{% endif %}" always;
{% endif %}
{% if security_rule.enable_csp is defined and security_rule.enable_csp == '1' %}
{% 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('-', '_') + '_' %}
{% if security_rule[prefix + 'enabled'] == '1' %}
{% set current_list = [] %}