]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1286287 - Add utility method to Bugzilla::CGI for configuring CSP headers
authorDylan Hardison <dylan@mozilla.com>
Thu, 28 Jul 2016 17:49:14 +0000 (13:49 -0400)
committerDylan Hardison <dylan@mozilla.com>
Thu, 28 Jul 2016 17:51:50 +0000 (13:51 -0400)
r=dkl,a=dylan

Bugzilla/CGI.pm
Bugzilla/CGI/ContentSecurityPolicy.pm [new file with mode: 0644]
Bugzilla/Install/Requirements.pm
META.json
META.yml
Makefile.PL

index b341a86f125932198f8f1e5d09eabe884fc96554..88dfeb4d26752ec9c99048c7567ea59fcea32355 100644 (file)
@@ -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 (file)
index 0000000..dc1cb34
--- /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.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
index abeab56fef98d5284b0259ad65ef66e50622f674..e04883ee2616fba4d12df719cd6247e743da1075 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 43d9e9b6779cade6ee7d21624c6f1d415fc23728..fc6ff80628cc8ed863c6fb1a7a28d217f50ca8a4 100644 (file)
--- a/META.json
+++ b/META.json
             }
          }
       },
+      "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" : {
index dc06c5ee37e3682bab7da66cf584f6fc05c4813d..5ed4ee620872626e0234641b63fb270469b84960 100644 (file)
--- a/META.yml
+++ b/META.yml
@@ -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:
index eaa2322229ebdf39ed3ac744873bb8635e1e3a2b..101bdf32bed49020060f5648141d5427fbc68746 100644 (file)
@@ -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") ) {