File Coverage

blib/lib/HTTP/SecureHeaders.pm
Criterion Covered Total %
statement 64 64 100.0
branch 34 34 100.0
condition 11 12 91.6
subroutine 16 16 100.0
pod 2 10 20.0
total 127 136 93.3


line stmt bran cond sub pod time code
1             package HTTP::SecureHeaders;
2 14     14   2840450 use strict;
  14         27  
  14         490  
3 14     14   72 use warnings;
  14         34  
  14         739  
4              
5 14     14   71 use Carp ();
  14         29  
  14         202  
6 14     14   58 use Scalar::Util ();
  14         24  
  14         1700  
7              
8             our $VERSION = "0.02";
9              
10             our %DEFAULT_HEADERS = (
11             content_security_policy => "default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'",
12             strict_transport_security => 'max-age=631138519',
13             x_content_type_options => 'nosniff',
14             x_download_options => 'noopen',
15             x_frame_options => 'SAMEORIGIN',
16             x_permitted_cross_domain_policies => 'none',
17             x_xss_protection => '1; mode=block',
18             referrer_policy => 'strict-origin-when-cross-origin',
19             );
20              
21             our %HTTP_FIELD_MAP = (
22             content_security_policy => 'Content-Security-Policy',
23             strict_transport_security => 'Strict-Transport-Security',
24             x_content_type_options => 'X-Content-Type-Options',
25             x_download_options => 'X-Download-Options',
26             x_frame_options => 'X-Frame-Options',
27             x_permitted_cross_domain_policies => 'X-Permitted-Cross-Domain-Policies',
28             x_xss_protection => 'X-XSS-Protection',
29             referrer_policy => 'Referrer-Policy',
30             );
31              
32 14     14   79 use constant OPT_OUT => \"";
  14         27  
  14         20972  
33              
34             sub new {
35 26     26 1 1382336 my ($class, %args) = @_;
36              
37 26         232 my %fields = (%DEFAULT_HEADERS, %args);
38              
39 26         121 for my $field (keys %fields) {
40 205 100       479 unless (exists $HTTP_FIELD_MAP{$field}) {
41 1         198 Carp::croak sprintf('unknown HTTP field. %s', $field);
42             }
43              
44 204         386 my $value = $fields{$field};
45 204         891 my $checker = $class->can("check_$field");
46 204 100       518 unless ($checker) {
47 1         137 Carp::croak sprintf('cannot find check function. %s', "check_$field")
48             }
49              
50             # undef value is available for optout from headers
51 203 100       427 next unless defined $value;
52              
53 199 100       431 unless ($checker->($value)) {
54 1         137 Carp::croak sprintf('invalid HTTP header value. %s:%s', $field, $value);
55             }
56             }
57              
58 23         121 bless \%fields, $class;
59             }
60              
61             sub apply {
62 22     22 1 16817 my ($self, $headers) = @_;
63              
64 22         100 my @fields = keys %$self;
65 22         69 for my $field (@fields) {
66 148         2434 $self->_apply($headers, $field);
67             }
68             }
69              
70             sub _apply {
71 148     148   276 my ($self, $headers, $field) = @_;
72              
73 148         259 my $http_field = $HTTP_FIELD_MAP{$field};
74              
75 148 100       305 unless (Scalar::Util::blessed($headers)) {
76 1         248 Carp::croak sprintf('headers must be HTTP::Headers or HasMethods["exists","get","set"]. %s', $headers);
77             }
78              
79 147 100 100     755 if ($headers->isa('HTTP::Headers')) {
    100 100        
80 96 100       402 if (defined $headers->header($http_field)) {
81 4 100       82 if ($headers->header($http_field) eq OPT_OUT) {
82 2         64 $headers->header($http_field, undef)
83             }
84             }
85             else {
86 92         2304 $headers->header($http_field, $self->{$field})
87             }
88             }
89             elsif ($headers->can('exists') && $headers->can('get') && $headers->can('set')) {
90 48 100       105 if (defined $headers->get($http_field)) {
    100          
91 2 100       15 if ($headers->get($http_field) eq OPT_OUT) {
92 1         10 $headers->set($http_field, undef);
93             }
94             }
95             elsif (!$headers->exists($http_field)) {
96 45         524 $headers->set($http_field, $self->{$field})
97             }
98             }
99             else {
100 3         480 Carp::croak sprintf('unknown headers: %s', $headers);
101             }
102             }
103              
104             # refs https://w3c.github.io/webappsec-csp/#csp-header
105             {
106             my $directive_map = {
107             # TODO implements directive_value checker
108             'child-src' => sub { 1 }, # serialized-source-list
109             'connect-src' => sub { 1 }, # serialized-source-list
110             'default-src' => sub { 1 }, # serialized-source-list
111             'font-src' => sub { 1 }, # serialized-source-list
112             'frame-src' => sub { 1 }, # serialized-source-list
113             'img-src' => sub { 1 }, # serialized-source-list
114             'manifest-src' => sub { 1 }, # serialized-source-list
115             'media-src' => sub { 1 }, # serialized-source-list
116             'object-src' => sub { 1 }, # serialized-source-list
117             'prefetch-src' => sub { 1 }, # serialized-source-list
118             'script-src' => sub { 1 }, # serialized-source-list
119             'script-src-elem' => sub { 1 }, # serialized-source-list
120             'script-src-attr' => sub { 1 }, # serialized-source-list
121             'style-src' => sub { 1 }, # serialized-source-list
122             'style-src-elem' => sub { 1 }, # serialized-source-list
123             'style-src-attr' => sub { 1 }, # serialized-source-list
124             'webrtc' => sub { $_[0] eq "'allow'" or $_[0] eq "'block'" },
125             'worker-src' => sub { 1 }, # serialized-source-list
126             'base-uri' => sub { 1 }, # serialized-source-list
127             'sandbox' => sub { 1 }, # "" / token *( required-ascii-whitespace token ),
128             'form-action' => sub { 1 }, # serialized-source-list
129             'frame-ancestors' => sub { 1 }, # ancestor-source-list
130             'navigate-to' => sub { 1 }, # serialized-source-list
131             'report-uri' => sub { 1 }, # uri-reference *( required-ascii-whitespace uri-reference )
132             'report-to' => sub { 1 }, # token
133             };
134              
135             sub check_content_security_policy {
136             # serialized-directive *( optional-ascii-whitespace ";" [ optional-ascii-whitespace serialized-directive ] )
137              
138             # serialized-directive = directive-name [ required-ascii-whitespace directive-value ]
139             # directive-name = 1*( ALPHA / DIGIT / "-" )
140             # directive-value = *( required-ascii-whitespace / ( %x21-%x2B / %x2D-%x3A / %x3C-%x7E ) )
141             # ; Directive values may contain whitespace and VCHAR characters,
142             # ; excluding ";" and ",". The second half of the definition
143             # ; above represents all VCHAR characters (%x21-%x7E)
144             # ; without ";" and "," (%x3B and %x2C respectively)
145              
146 34     34 0 179561 my @directives = split ';', $_[0];
147 34         82 for my $directive (@directives) {
148 134         638 my ($name, $value) = $directive =~ m!\s?([A-Za-z0-9\-]+)\s([^\s;,][^;,]+)!;
149 134 100 66     510 unless ($name && $value) {
150 4         10 return !!0
151             }
152 130         290 my $checker = $directive_map->{$name};
153 130 100       321 unless ($checker) {
154 2         8 return !!0
155             }
156 128 100       272 unless ($checker->($value)) {
157 1         4 return !!0
158             }
159             }
160 27         113 return !!1;
161             }
162             }
163              
164              
165             # refs https://datatracker.ietf.org/doc/html/rfc6797
166             # refs https://www.chromium.org/hsts/
167             sub check_strict_transport_security {
168 41     41 0 171194 $_[0] =~ m!\Amax-age=(?:[0-9]+)(?:\s?;\s?includeSubDomains)?(?:\s?;\s?preload)?\z!
169             }
170              
171             # refs http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx
172             sub check_x_content_type_options {
173 27     27 0 240629 $_[0] eq 'nosniff'
174             }
175              
176             # refs http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx
177             sub check_x_download_options {
178 26     26 0 255406 $_[0] eq 'noopen'
179             }
180              
181             # refs https://www.rfc-editor.org/rfc/rfc7034#section-2
182             sub check_x_frame_options {
183 31 100   31 0 161592 $_[0] eq 'SAMEORIGIN' or
184             $_[0] eq 'DENY'
185             # ALLOW-FROM # deprecated
186             }
187              
188             # refs https://www.adobe.com/devnet-docs/acrobatetk/tools/AppSec/CrossDomain_PolicyFile_Specification.pdf
189             sub check_x_permitted_cross_domain_policies {
190 33     33 0 348755 $_[0] =~ m!\A(?:none|master-only|by-content-type|by-ftp-filename|all)\z!
191             }
192              
193             # refs https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
194             sub check_x_xss_protection {
195 31 100 100 31 0 160327 $_[0] eq '0' or
196             $_[0] eq '1' or
197             $_[0] eq '1; mode=block'
198              
199             # `report=` directive not recommend
200             }
201              
202             # refs https://w3c.github.io/webappsec-referrer-policy/#referrer-policy-header
203             {
204             my $referrer_policy_values = {
205             'strict-origin-when-cross-origin' => 1,
206             'no-referrer' => 1,
207             'no-referrer-when-downgrade' => 1,
208             'same-origin' => 1,
209             'origin' => 1,
210             'strict-origin' => 1,
211             'origin-when-cross-origin' => 1,
212             'unsafe-url' => 1,
213             };
214              
215             # empty string cannot pass.
216             sub check_referrer_policy {
217 36     36 0 275673 exists $referrer_policy_values->{$_[0]}
218             }
219             }
220              
221             1;
222             __END__