summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Bugzilla/CGI.pm88
-rw-r--r--Bugzilla/CGI/ContentSecurityPolicy.pm354
-rw-r--r--Bugzilla/Install/Requirements.pm1
-rw-r--r--META.json13
-rw-r--r--META.yml7
-rw-r--r--Makefile.PL11
6 files changed, 464 insertions, 10 deletions
diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm
index e44907161..78987ab71 100644
--- a/Bugzilla/CGI.pm
+++ b/Bugzilla/CGI.pm
@@ -31,6 +31,15 @@ BEGIN {
*AUTOLOAD = \&CGI::AUTOLOAD;
}
+use constant DEFAULT_CSP => (
+ default_src => [ 'self' ],
+ script_src => [ 'self', 'https://login.persona.org', 'unsafe-inline', 'unsafe-eval' ],
+ child_src => [ 'self', 'https://login.persona.org' ],
+ img_src => [ 'self', 'https://secure.gravatar.com' ],
+ style_src => [ 'self', 'unsafe-inline' ],
+ disable => 1,
+);
+
sub _init_bz_cgi_globals {
my $invocant = shift;
# We need to disable output buffering - see bug 179174
@@ -130,6 +139,38 @@ sub target_uri {
}
}
+sub content_security_policy {
+ my ($self, %add_params) = @_;
+ if (Bugzilla->has_feature('csp')) {
+ require Bugzilla::CGI::ContentSecurityPolicy;
+ return $self->{Bugzilla_csp} if $self->{Bugzilla_csp};
+ my %params = DEFAULT_CSP;
+ if (%add_params) {
+ foreach my $key (keys %add_params) {
+ if (defined $add_params{$key}) {
+ $params{$key} = $add_params{$key};
+ }
+ else {
+ delete $params{$key};
+ }
+ }
+ }
+ return $self->{Bugzilla_csp} = Bugzilla::CGI::ContentSecurityPolicy->new(%params);
+ }
+ return undef;
+}
+
+sub csp_nonce {
+ my ($self) = @_;
+
+ if (Bugzilla->has_feature('csp')) {
+ my $csp = $self->content_security_policy;
+ return $csp->nonce if $csp->has_nonce;
+ }
+
+ return '';
+}
+
# We want this sorted plus the ability to exclude certain params
sub canonicalise_query {
my ($self, @exclude) = @_;
@@ -339,12 +380,20 @@ sub close_standby_message {
# Override header so we can add the cookies in
sub header {
my $self = shift;
+
+ my %headers;
my $user = Bugzilla->user;
# If there's only one parameter, then it's a Content-Type.
if (scalar(@_) == 1) {
- # Since we're adding parameters below, we have to name it.
- unshift(@_, '-type' => shift(@_));
+ %headers = ('-type' => shift(@_));
+ }
+ else {
+ %headers = @_;
+ }
+
+ if ($self->{'_content_disp'}) {
+ $headers{'-content_disposition'} = $self->{'_content_disp'};
}
if (!$user->id && $user->authorizer->can_login
@@ -368,10 +417,9 @@ sub header {
-value => Bugzilla->github_secret,
-httponly => 1);
}
-
# Add the cookies in if we have any
if (scalar(@{$self->{Bugzilla_cookie_list}})) {
- unshift(@_, '-cookie' => $self->{Bugzilla_cookie_list});
+ $headers{'-cookie'} = $self->{Bugzilla_cookie_list};
}
# Add Strict-Transport-Security (STS) header if this response
@@ -385,28 +433,36 @@ sub header {
{
$sts_opts .= '; includeSubDomains';
}
- unshift(@_, '-strict_transport_security' => $sts_opts);
+ $headers{'-strict_transport_security'} = $sts_opts;
}
# Add X-Frame-Options header to prevent framing and subsequent
# possible clickjacking problems.
unless ($self->url_is_attachment_base) {
- unshift(@_, '-x_frame_options' => 'SAMEORIGIN');
+ $headers{'-x_frame_options'} = 'SAMEORIGIN';
}
if ($self->{'_content_disp'}) {
- unshift(@_, '-content_disposition' => $self->{'_content_disp'});
+ $headers{'-content_disposition'} = $self->{'_content_disp'};
}
# Add X-XSS-Protection header to prevent simple XSS attacks
# and enforce the blocking (rather than the rewriting) mode.
- unshift(@_, '-x_xss_protection' => '1; mode=block');
+ $headers{'-x_xss_protection'} = '1; mode=block';
# Add X-Content-Type-Options header to prevent browsers sniffing
# the MIME type away from the declared Content-Type.
- unshift(@_, '-x_content_type_options' => 'nosniff');
+ $headers{'-x_content_type_options'} = 'nosniff';
+
+ my $csp = $self->content_security_policy;
+ $csp->add_cgi_headers(\%headers) if defined $csp;
- return $self->SUPER::header(@_) || "";
+ Bugzilla::Hook::process('cgi_headers',
+ { cgi => $self, headers => \%headers }
+ );
+ $self->{_header_done} = 1;
+
+ return $self->SUPER::header(%headers) || "";
}
sub param {
@@ -731,6 +787,18 @@ correctly, using C<print> or the mod_perl APIs as appropriate.
To remove (expire) a cookie, use C<remove_cookie>.
+=item C<content_security_policy>
+
+Set a Content Security Policy for the current request. This is a no-op if the 'csp' feature
+is not available. The arguments to this method are passed to the constructor of L<Bugzilla::CGI::ContentSecurityPolicy>,
+consult that module for a list of what directives are supported.
+
+=item C<csp_nonce>
+
+Returns a CSP nonce value if CSP is available and 'nonce' is listed as a source in a CSP *_src directive.
+
+If there is no nonce used, or CSP is not available, this returns the empty string.
+
=item C<remove_cookie>
This is a wrapper around send_cookie, setting an expiry date in the past,
diff --git a/Bugzilla/CGI/ContentSecurityPolicy.pm b/Bugzilla/CGI/ContentSecurityPolicy.pm
new file mode 100644
index 000000000..74bce6374
--- /dev/null
+++ b/Bugzilla/CGI/ContentSecurityPolicy.pm
@@ -0,0 +1,354 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::CGI::ContentSecurityPolicy;
+
+use 5.10.1;
+use strict;
+use warnings;
+use Moo;
+use MooX::StrictConstructor;
+use Types::Standard qw(Bool Str ArrayRef);
+use Type::Utils;
+
+use Bugzilla::Util qw(generate_random_password);
+
+my $SRC_KEYWORD = enum['none', 'self', 'unsafe-inline', 'unsafe-eval', 'nonce'];
+my $SRC_URI = declare as Str, where {
+ $_ =~ m{
+ ^(?: https?:// )? # optional http:// or https://
+ [*A-Za-z0-9.-]+ # hostname including wildcards. Possibly too permissive.
+ (?: :[0-9]+ )? # optional port
+ }x;
+};
+my $SRC = $SRC_KEYWORD | $SRC_URI;
+my $SOURCE_LIST = ArrayRef[$SRC];
+my $REFERRER_KEYWORD = enum [qw(
+ no-referrer no-referrer-when-downgrade
+ origin origin-when-cross-origin unsafe-url
+)];
+
+my @ALL_BOOL = qw( sandbox upgrade_insecure_requests );
+my @ALL_SRC = qw(
+ default_src child_src connect_src
+ font_src img_src media_src
+ object_src script_src style_src
+);
+
+has \@ALL_SRC => ( is => 'ro', isa => $SOURCE_LIST, predicate => 1 );
+has \@ALL_BOOL => ( is => 'ro', isa => Bool, default => 0 );
+has 'report_uri' => ( is => 'ro', isa => Str, predicate => 1 );
+has 'base_uri' => ( is => 'ro', isa => Str, predicate => 1 );
+has 'report_only' => ( is => 'ro', isa => Bool );
+has 'referrer' => ( is => 'ro', isa => $REFERRER_KEYWORD, predicate => 1 );
+has 'value' => ( is => 'lazy' );
+has 'nonce' => ( is => 'lazy', init_arg => undef, predicate => 1 );
+has 'disable' => ( is => 'ro', isa => Bool, default => 0 );
+
+sub _has_directive {
+ my ($self, $directive) = @_;
+ my $method = 'has_' . $directive;
+ return $self->$method;
+}
+
+sub header_names {
+ my ($self) = @_;
+ my @names = ('Content-Security-Policy', 'X-Content-Security-Policy', 'X-WebKit-CSP');
+ if ($self->report_only) {
+ return map { $_ . '-Report-Only' } @names;
+ }
+ else {
+ return @names;
+ }
+}
+
+sub add_cgi_headers {
+ my ($self, $headers) = @_;
+ return if $self->disable;
+ foreach my $name ($self->header_names) {
+ $headers->{"-$name"} = $self->value;
+ }
+}
+
+sub _build_value {
+ my $self = shift;
+ my @result;
+
+ my @list_directives = (@ALL_SRC);
+ my @boolean_directives = (@ALL_BOOL);
+ my @single_directives = qw(report_uri base_uri);
+
+ foreach my $directive (@list_directives) {
+ next unless $self->_has_directive($directive);
+ my @values = map { $self->_quote($_) } @{ $self->$directive };
+ if (@values) {
+ push @result, join(' ', _name($directive), @values);
+ }
+ }
+
+ foreach my $directive (@single_directives) {
+ next unless $self->_has_directive($directive);
+ my $value = $self->$directive;
+ if (defined $value) {
+ push @result, _name($directive) . ' ' . $value;
+ }
+ }
+
+ foreach my $directive (@boolean_directives) {
+ if ($self->$directive) {
+ push @result, _name($directive);
+ }
+ }
+
+ return join('; ', @result);
+}
+
+sub _build_nonce {
+ return generate_random_password(48);
+}
+
+sub _name {
+ my $name = shift;
+ $name =~ tr/_/-/;
+ return $name;
+}
+
+sub _quote {
+ my ($self, $val) = @_;
+
+ if ($val eq 'nonce') {
+ return q{'nonce-} . $self->nonce . q{'};
+ }
+ elsif ($SRC_KEYWORD->check($val)) {
+ return qq{'$val'};
+ }
+ else {
+ return $val;
+ }
+}
+
+
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::CGI::ContentSecurityPolicy - Object-oriented interface to generating CSP directives and adding them to headers.
+
+=head1 SYNOPSIS
+
+ use Bugzilla::CGI::ContentSecurityPolicy;
+
+ my $csp = Bugzilla::CGI::ContentSecurityPolicy->new(
+ default_src => [ 'self' ],
+ style_src => [ 'self', 'unsafe-inline' ],
+ script_src => [ 'self', 'nonce' ],
+ child_src => ['none'],
+ report_uri => '/csp-report.cgi',
+ referrer => 'origin-when-cross-origin',
+ );
+ $csp->headers_names # returns a list of header names and depends on the value of $self->report_only
+ $csp->value # returns the string representation of the policy.
+ $csp->add_cgi_headers(\%hashref); # will insert entries compatible with CGI.pm's $cgi->headers() method into the provided hashref.
+
+=head1 DESCRIPTION
+
+This class provides an object interface to constructing Content Security Policies.
+
+Rather than use this module, scripts should call $cgi->content_security_policy() which constructs the CSP headers
+and registers them for the current request.
+
+See L<Bugzilla::CGI> for details.
+
+=head1 ATTRIBUTES
+
+Generally all CSP directives are available as attributes to the constructor,
+with dashes replaced by underscores. All directives that can be lists must be
+passed as array references, and the quoting rules for urls and keywords like
+'self' or 'none' is handled automatically.
+
+=head2 report_only
+
+If this is true, then the the -Report-Only version of the headers will be produced, so nothing will be blocked.
+
+=head2 disable
+
+If this is true, no CSP headers will be used at all.
+
+=head2 base_uri
+
+The base-uri directive defines the URIs that a user agent may use as the
+document base URL. If this value is absent, then any URI is allowed. If this
+directive is absent, the user agent will use the value in the base element.
+
+=head2 child_src
+
+The child-src directive defines the valid sources for web workers and nested
+browsing contexts loaded using elements such as <frame> and <iframe>. This
+directive is preferred over the frame-src directive, which is deprecated. For
+workers, non-compliant requests are treated as fatal network errors by the user
+agent.
+
+=head2 connect_src
+
+The connect-src directive defines valid sources for fetch, XMLHttpRequest, WebSocket, and EventSource connections.
+
+=head2 default_src
+
+The default-src directive defines the security policy for types of content which are not expressly called out by more specific directives. This directive covers the following directives:
+
+=over 4
+
+=item *
+
+L</child_src>
+
+=item *
+
+L</connect_src>
+
+=item *
+
+L</font_src>
+
+=item *
+
+L</img_src>
+
+=item *
+
+L</media_src>
+
+=item *
+
+L</object_src>
+
+=item *
+
+L</script_src>
+
+=item *
+
+L</style_src>
+
+=back
+
+=head2 font_src
+
+The font-src directive specifies valid sources for fonts loaded using @font-face.
+
+=head2 img_src
+
+The img-src directive specifies valid sources of images and favicons.
+
+=head2 manifest_src
+
+The manifest-src directive specifies which manifest can be applied to the resource.
+
+=head2 media_src
+
+The media-src directive specifies valid sources for loading media using the <audio> and <video> elements.
+
+=head2 object_src
+
+The object-src directive specifies valid sources for the <object>, <embed>, and <applet> elements.
+
+=head2 referrer
+
+The referrer directive specifies information in the B<referer> (sic) header for
+links away from a page. Valid values are C<no-referrer>,
+C<no-referrer-when-downgrade>, C<origin>, C<origin-when-cross-origin>, and
+C<unsafe-url>.
+
+=head2 report_uri
+
+The report-uri directive instructs the user agent to report attempts to violate
+the Content Security Policy. These violation reports consist of JSON documents
+sent via an HTTP POST request to the specified URI.
+
+=head2 sandbox
+
+The sandbox directive applies restrictions to a page's actions including
+preventing popups, preventing the execution of plugins and scripts, and
+enforcing a same-origin policy.
+
+=head2 script_src
+
+The script-src directive specifies valid sources for JavaScript. When either the
+script-src or the default-src directive is included, inline script and eval()
+are disabled unless you specify 'unsafe-inline' and 'unsafe-eval', respectively.
+In Chrome 49 and later, 'script-src http' will match both HTTP and HTTPS.
+
+=head2 style_src
+
+The style-src directive specifies valid sources for stylesheets. This includes
+both externally-loaded stylesheets and inline use of the C<style> element and
+HTML style attributes. Stylesheets from sources that aren't included in the
+source list are not requested or loaded. When either the style-src or the
+default-src directive is included, inline use of the C<style> element and HTML
+style attributes are disabled unless you specify 'unsafe-inline'.
+
+=head2 upgrade_insecure_requests
+
+The upgrade-insecure-requests directive instructs user agents to treat all of a
+site's unsecure URL's (those serverd over HTTP) as though they have been
+replaced with secure URL's (those served over HTTPS). This directive is intended
+for web sites with large numbers of unsecure legacy URL's that need to be
+rewritten.
+
+=head1 METHODS
+
+=head2 header_names()
+
+This returns a list of header names. This will typically be
+C<Content-Security-Policy>, C<X-Content-Security-Policy>, and C<X-WebKit-CSP>.
+
+=head2 value()
+
+This returns the value or right-of-colon part of the header.
+
+=head2 add_cgi_headers($headers)
+
+This adds C<header_value()> to C<$headers> in a format that is compatible with
+L<CGI>'s headers() method.
+
+=head2 nonce() / has_nonce()
+
+This is unique value that can used if the 'nonce' is used as a source for
+style_src or script_src.
+
+=head1 B<Methods in need of POD>
+
+=over 4
+
+=item has_report_uri
+
+=item has_child_src
+
+=item has_connect_src
+
+=item has_script_src
+
+=item has_media_src
+
+=item has_base_uri
+
+=item has_img_src
+
+=item has_referrer
+
+=item has_style_src
+
+=item has_default_src
+
+=item has_object_src
+
+=item has_font_src
+
+=back
diff --git a/Bugzilla/Install/Requirements.pm b/Bugzilla/Install/Requirements.pm
index 43c441d6b..22cf146c5 100644
--- a/Bugzilla/Install/Requirements.pm
+++ b/Bugzilla/Install/Requirements.pm
@@ -86,6 +86,7 @@ use constant FEATURE_FILES => (
'Bugzilla/WebService.pm', 'Bugzilla/WebService/*.pm'],
rest => ['Bugzilla/API/Server.pm', 'rest.cgi', 'Bugzilla/API/*/*.pm',
'Bugzilla/API/*/Server.pm', 'Bugzilla/API/*/Resource/*.pm'],
+ csp => ['Bugzilla/CGI/ContentSecurityPolicy.pm'],
psgi => ['app.psgi'],
moving => ['importxml.pl'],
auth_ldap => ['Bugzilla/Auth/Verify/LDAP.pm'],
diff --git a/META.json b/META.json
index 38b149236..a853c4a2c 100644
--- a/META.json
+++ b/META.json
@@ -77,6 +77,7 @@
"JSON::RPC" : "0",
"LWP::UserAgent" : "0",
"MIME::Parser" : "5.406",
+ "MooX::StrictConstructor" : "0.008",
"Mozilla::CA" : "0",
"Net::SFTP" : "0",
"PatchReader" : "v0.9.6",
@@ -84,6 +85,7 @@
"Template::Plugin::GD::Image" : "0",
"Test::Taint" : "1.06",
"TheSchwartz" : "1.10",
+ "Type::Tiny" : "1",
"XML::Simple" : "0",
"XML::Twig" : "0",
"XMLRPC::Lite" : "0.712"
@@ -91,6 +93,17 @@
}
}
},
+ "csp" : {
+ "description" : "Content-Security-Policy support",
+ "prereqs" : {
+ "runtime" : {
+ "requires" : {
+ "MooX::StrictConstructor" : "0.008",
+ "Type::Tiny" : "1"
+ }
+ }
+ }
+ },
"detect_charset" : {
"description" : "Automatic charset detection for text attachments",
"prereqs" : {
diff --git a/META.yml b/META.yml
index 06d656afd..78810ed27 100644
--- a/META.yml
+++ b/META.yml
@@ -58,6 +58,7 @@ optional_features:
JSON::RPC: '0'
LWP::UserAgent: '0'
MIME::Parser: '5.406'
+ MooX::StrictConstructor: '0.008'
Mozilla::CA: '0'
Net::SFTP: '0'
PatchReader: v0.9.6
@@ -65,9 +66,15 @@ optional_features:
Template::Plugin::GD::Image: '0'
Test::Taint: '1.06'
TheSchwartz: '1.10'
+ Type::Tiny: '1'
XML::Simple: '0'
XML::Twig: '0'
XMLRPC::Lite: '0.712'
+ csp:
+ description: 'Content-Security-Policy support'
+ requires:
+ MooX::StrictConstructor: '0.008'
+ Type::Tiny: '1'
detect_charset:
description: 'Automatic charset detection for text attachments'
requires:
diff --git a/Makefile.PL b/Makefile.PL
index d4d29dfe3..f3b03f0db 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -283,6 +283,17 @@ my %optional_features = (
}
}
},
+ csp => {
+ description => 'Content-Security-Policy support',
+ prereqs => {
+ runtime => {
+ requires => {
+ 'Type::Tiny' => 1,
+ 'MooX::StrictConstructor' => 0.008,
+ }
+ }
+ }
+ },
);
for my $file ( glob("extensions/*/Config.pm") ) {