diff options
-rw-r--r-- | Bugzilla/CGI.pm | 88 | ||||
-rw-r--r-- | Bugzilla/CGI/ContentSecurityPolicy.pm | 354 | ||||
-rw-r--r-- | Bugzilla/Install/Requirements.pm | 1 | ||||
-rw-r--r-- | META.json | 13 | ||||
-rw-r--r-- | META.yml | 7 | ||||
-rw-r--r-- | Makefile.PL | 11 |
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'], @@ -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" : { @@ -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") ) { |