From 12516232dbc08af68e7b9e63d1639d8e0c353737 Mon Sep 17 00:00:00 2001 From: Dylan William Hardison Date: Thu, 6 Oct 2016 15:18:38 -0400 Subject: Bug 1293689 - Bring Bugzilla::CGI::ContentSecurityPolicy to BMO (Backport Bug 1286287) --- Bugzilla/CGI.pm | 88 ++++++++- Bugzilla/CGI/ContentSecurityPolicy.pm | 354 ++++++++++++++++++++++++++++++++++ Bugzilla/Install/Requirements.pm | 1 + META.json | 13 ++ META.yml | 7 + Makefile.PL | 11 ++ 6 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 Bugzilla/CGI/ContentSecurityPolicy.pm 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 or the mod_perl APIs as appropriate. To remove (expire) a cookie, use C. +=item C + +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, +consult that module for a list of what directives are supported. + +=item C + +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 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 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 and