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 13     13   578244 use strict;
  13         89  
  13         279  
3 13     13   49 use warnings;
  13         16  
  13         239  
4              
5 13     13   45 use Carp ();
  13         15  
  13         118  
6 13     13   40 use Scalar::Util ();
  13         14  
  13         1034  
7              
8             our $VERSION = "0.01";
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 13     13   67 use constant OPT_OUT => \"";
  13         16  
  13         12936  
33              
34             sub new {
35 20     20 1 34133 my ($class, %args) = @_;
36              
37 20         93 my %fields = (%DEFAULT_HEADERS, %args);
38              
39 20         68 for my $field (keys %fields) {
40 147 100       223 unless (exists $HTTP_FIELD_MAP{$field}) {
41 1         115 Carp::croak sprintf('unknown HTTP field. %s', $field);
42             }
43              
44 146         164 my $value = $fields{$field};
45 146         351 my $checker = $class->can("check_$field");
46 146 100       237 unless ($checker) {
47 1         69 Carp::croak sprintf('cannot find check function. %s', "check_$field")
48             }
49              
50             # undef value is available for optout from headers
51 145 100       170 next unless defined $value;
52              
53 142 100       169 unless ($checker->($value)) {
54 1         66 Carp::croak sprintf('invalid HTTP header value. %s:%s', $field, $value);
55             }
56             }
57              
58 17         49 bless \%fields, $class;
59             }
60              
61             sub apply {
62 16     16 1 6248 my ($self, $headers) = @_;
63              
64 16         40 my @fields = keys %$self;
65 16         23 for my $field (@fields) {
66 100         498 $self->_apply($headers, $field);
67             }
68             }
69              
70             sub _apply {
71 100     100   112 my ($self, $headers, $field) = @_;
72              
73 100         106 my $http_field = $HTTP_FIELD_MAP{$field};
74              
75 100 100       178 unless (Scalar::Util::blessed($headers)) {
76 1         94 Carp::croak sprintf('headers must be HTTP::Headers or HasMethods["exists","get","set"]. %s', $headers);
77             }
78              
79 99 100 100     357 if ($headers->isa('HTTP::Headers')) {
    100 100        
80 48 100       161 if (defined $headers->header($http_field)) {
81 2 100       10 if ($headers->header($http_field) eq OPT_OUT) {
82 1         7 $headers->header($http_field, undef)
83             }
84             }
85             else {
86 46         229 $headers->header($http_field, $self->{$field})
87             }
88             }
89             elsif ($headers->can('exists') && $headers->can('get') && $headers->can('set')) {
90 48 100       69 if (defined $headers->get($http_field)) {
    100          
91 2 100       9 if ($headers->get($http_field) eq OPT_OUT) {
92 1         5 $headers->set($http_field, undef);
93             }
94             }
95             elsif (!$headers->exists($http_field)) {
96 45         286 $headers->set($http_field, $self->{$field})
97             }
98             }
99             else {
100 3         196 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 29     29 0 6694 my @directives = split ';', $_[0];
147 29         51 for my $directive (@directives) {
148 109         367 my ($name, $value) = $directive =~ m!\s?([A-Za-z0-9\-]+)\s([^\s;,][^;,]+)!;
149 109 100 66     298 unless ($name && $value) {
150 4         16 return !!0
151             }
152 105         147 my $checker = $directive_map->{$name};
153 105 100       136 unless ($checker) {
154 2         10 return !!0
155             }
156 103 100       143 unless ($checker->($value)) {
157 1         5 return !!0
158             }
159             }
160 22         68 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 33     33 0 5844 $_[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 20     20 0 2739 $_[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 19     19 0 2606 $_[0] eq 'noopen'
179             }
180              
181             # refs https://www.rfc-editor.org/rfc/rfc7034#section-2
182             sub check_x_frame_options {
183 24 100   24 0 3581 $_[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 25     25 0 3865 $_[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 23 100 100 23 0 4443 $_[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 29     29 0 4576 exists $referrer_policy_values->{$_[0]}
218             }
219             }
220              
221             1;
222             __END__