diff options
-rw-r--r-- | Bugzilla/CGI.pm | 54 | ||||
-rw-r--r-- | Bugzilla/CGI/ContentSecurityPolicy.pm | 354 | ||||
-rw-r--r-- | Bugzilla/Install/Requirements.pm | 1 | ||||
-rw-r--r-- | META.json | 12 | ||||
-rw-r--r-- | META.yml | 6 | ||||
-rw-r--r-- | Makefile.PL | 12 |
6 files changed, 439 insertions, 0 deletions
diff --git a/Bugzilla/CGI.pm b/Bugzilla/CGI.pm index b341a86f1..88dfeb4d2 100644 --- a/Bugzilla/CGI.pm +++ b/Bugzilla/CGI.pm @@ -22,6 +22,12 @@ use Bugzilla::Install::Util qw(i_am_persistent); use File::Basename; +use constant DEFAULT_CSP => ( + default_src => [ 'self' ], + script_src => [ 'self', 'unsafe-inline', 'unsafe-eval' ], + style_src => [ 'self', 'unsafe-inline' ], +); + sub _init_bz_cgi_globals { my $invocant = shift; # We need to disable output buffering - see bug 179174 @@ -102,6 +108,39 @@ sub new { return $self; } +sub content_security_policy { + my ($self) = @_; + if (Bugzilla->has_feature('csp')) { + require Bugzilla::CGI::ContentSecurityPolicy; + return $self->{Bugzilla_csp} if $self->{Bugzilla_csp}; + my %params = DEFAULT_CSP; + if (@_) { + my %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) = @_; @@ -336,6 +375,9 @@ sub header { # the MIME type away from the declared Content-Type. $headers{'-x_content_type_options'} = 'nosniff'; + my $csp = $self->content_security_policy; + $csp->add_cgi_headers(\%headers) if defined $csp; + Bugzilla::Hook::process('cgi_headers', { cgi => $self, headers => \%headers } ); @@ -646,6 +688,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..dc1cb3440 --- /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.14.0; +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 headers() + +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 abeab56fe..e04883ee2 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'], @@ -50,6 +50,18 @@ } } }, + "csp" : { + "description" : "Content-Security-Policy support", + "prereqs" : { + "runtime" : { + "requires" : { + "Moo" : "2", + "MooX::StrictConstructor" : "0.008", + "Type::Tiny" : "1" + } + } + } + }, "detect_charset" : { "description" : "Automatic charset detection for text attachments", "prereqs" : { @@ -35,6 +35,12 @@ optional_features: description: 'RADIUS Authentication' requires: Authen::Radius: '0' + csp: + description: 'Content-Security-Policy support' + requires: + Moo: '2' + 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 eaa232222..101bdf32b 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -296,6 +296,18 @@ my %optional_features = ( } } }, + csp => { + description => 'Content-Security-Policy support', + prereqs => { + runtime => { + requires => { + Moo => 2, + 'Type::Tiny' => 1, + 'MooX::StrictConstructor' => 0.008, + } + } + } + }, ); for my $file ( glob("extensions/*/Config.pm") ) { |