]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1293689 - Bring Bugzilla::CGI::ContentSecurityPolicy to BMO (Backport Bug 1286287)
authorDylan William Hardison <dylan@hardison.net>
Thu, 6 Oct 2016 19:18:38 +0000 (15:18 -0400)
committerDylan William Hardison <dylan@hardison.net>
Thu, 6 Oct 2016 19:18:56 +0000 (15:18 -0400)
Bugzilla/CGI.pm
Bugzilla/CGI/ContentSecurityPolicy.pm [new file with mode: 0644]
Bugzilla/Install/Requirements.pm
META.json
META.yml
Makefile.PL

index e449071611c226e473a317cc4f1c1d3a3a5619b6..78987ab711a6a43396312356041f84e2813ce05d 100644 (file)
@@ -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 (file)
index 0000000..74bce63
--- /dev/null
@@ -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
index 43c441d6b5fd4390f26a3f44a5dfcd792c0752da..22cf146c5752bce682f3235d399a4861f4ca7588 100644 (file)
@@ -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'],
index 38b149236cd0c33311fdb075ea51203ceae88995..a853c4a2cfb59b47d0ed4628677512aa2f6502bf 100644 (file)
--- 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"
             }
          }
       },
+      "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" : {
index 06d656afd0af18108f429c997bb4a18deecbf7f5..78810ed271d9fc1555cea6a260be40bdd1a0bde1 100644 (file)
--- 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:
index d4d29dfe3debcb5fd6842e42369ae878553ac7a8..f3b03f0dba08a500ad185d9f3cc9e80a74264da4 100644 (file)
@@ -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") ) {