From 742fab1ca9c0e66f46fbaf08af26c31b5e5fd9a3 Mon Sep 17 00:00:00 2001 From: Dylan Hardison Date: Thu, 28 Jul 2016 13:49:14 -0400 Subject: Bug 1286287 - Add utility method to Bugzilla::CGI for configuring CSP headers r=dkl,a=dylan --- Bugzilla/CGI.pm | 54 ++++++ Bugzilla/CGI/ContentSecurityPolicy.pm | 354 ++++++++++++++++++++++++++++++++++ Bugzilla/Install/Requirements.pm | 1 + META.json | 12 ++ META.yml | 6 + Makefile.PL | 12 ++ 6 files changed, 439 insertions(+) create mode 100644 Bugzilla/CGI/ContentSecurityPolicy.pm 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 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..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 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