Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.16% covered (warning)
81.16%
56 / 69
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CspHeaderGenerator
81.16% covered (warning)
81.16%
56 / 69
42.86% covered (danger)
42.86%
3 / 7
31.88
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCspHeader
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
9.65
 createHeaderObject
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getReportToHeader
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 getNetworkErrorLoggingHeader
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2
3/**
4 * Class CspHeaderGenerator
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Moravian Library 2019.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Security
25 * @author   Josef Moravec <moravec@mzk.cz>
26 * @license  https://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Page
28 */
29
30namespace VuFind\Security;
31
32use Laminas\Http\Header\ContentSecurityPolicy;
33use Laminas\Http\Header\ContentSecurityPolicyReportOnly;
34use Laminas\Http\Header\GenericHeader;
35
36use function in_array;
37
38/**
39 * VuFind class for generating Content Security Policy http headers.
40 * Also generates related headers like NEL (network error logging)
41 * and reporting headers like Report-To.
42 *
43 * @category VuFind
44 * @package  Security
45 * @author   Josef Moravec <moravec@mzk.cz>
46 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
47 * @link     https://vufind.org/wiki/ Wiki
48 *
49 * @SuppressWarnings(PHPMD.NumberOfChildren)
50 */
51class CspHeaderGenerator implements
52    \Laminas\Log\LoggerAwareInterface
53{
54    use \VuFind\Log\LoggerAwareTrait;
55
56    /**
57     * Configuration for generator from contensecuritypolicy.ini
58     *
59     * @var \Laminas\Config\Config
60     */
61    protected $config;
62
63    /**
64     * Generated nonce used for one request
65     *
66     * @var string
67     */
68    protected $nonce;
69
70    /**
71     * List of directives that can work with nonce
72     *
73     * @var string[]
74     */
75    protected $scriptDirectives = ['script-src', 'script-src-elem'];
76
77    /**
78     * CspHeaderGenerator constructor.
79     *
80     * @param \Laminas\Config\Config          $config         Configuration
81     * @param \VuFind\Security\NonceGenerator $nonceGenerator Nonce generator
82     */
83    public function __construct($config, $nonceGenerator)
84    {
85        $this->nonce = $nonceGenerator->getNonce();
86        $this->config = $config;
87    }
88
89    /**
90     * Create all relevant CSP-related headers based on given configuration
91     *
92     * @return array
93     */
94    public function getHeaders()
95    {
96        $headers = [];
97        if ($cspHeader = $this->getCspHeader()) {
98            $headers[] = $cspHeader;
99        }
100        if ($reportToHeader = $this->getReportToHeader()) {
101            $headers[] = $reportToHeader;
102        }
103        if ($nelHeader = $this->getNetworkErrorLoggingHeader()) {
104            $headers[] = $nelHeader;
105        }
106        return $headers;
107    }
108
109    /**
110     * Create CSP header base on given configuration
111     *
112     * @return ContentSecurityPolicy
113     *
114     * @deprecated Use getCspHeader instead
115     */
116    public function getHeader()
117    {
118        return $this->getCspHeader();
119    }
120
121    /**
122     * Create CSP header base on given configuration
123     *
124     * @return ContentSecurityPolicy
125     */
126    public function getCspHeader()
127    {
128        $cspHeader = $this->createHeaderObject();
129        $directives = $this->config->Directives ?? [];
130        if (!$cspHeader || !$directives) {
131            return null;
132        }
133        foreach ($directives as $name => $value) {
134            $sources = $value->toArray();
135            if (
136                in_array($name, $this->scriptDirectives)
137                && $this->config->CSP->use_nonce
138            ) {
139                $sources[] = "'nonce-$this->nonce'";
140            }
141            // Warn about report-to being used in place of report-uri
142            if ($name == 'report-to') {
143                foreach ($sources as $source) {
144                    if (str_contains($source, '://')) {
145                        $this->logWarning('CSP report-to directive should not be a URI.');
146                    }
147                }
148            }
149            $cspHeader->setDirective($name, $sources);
150        }
151        return $cspHeader;
152    }
153
154    /**
155     * Create header object
156     *
157     * @return ContentSecurityPolicy
158     */
159    protected function createHeaderObject()
160    {
161        $mode = $this->config->CSP->enabled[APPLICATION_ENV] ?? 'report_only';
162        if (!$mode) {
163            return null;
164        }
165        return ('report_only' === $mode)
166            ? new ContentSecurityPolicyReportOnly()
167            : new ContentSecurityPolicy();
168    }
169
170    /**
171     * Create Report-To header based on given configuration
172     *
173     * @return ?GenericHeader
174     */
175    public function getReportToHeader()
176    {
177        $reportToHeader = new GenericHeader();
178        $reportToHeader->setFieldName('Report-To');
179        $groupsText = [];
180
181        $reportTo = $this->config->ReportTo;
182        foreach ($reportTo['groups'] ?? [] as $groupName) {
183            $configSectionName = 'ReportTo' . $groupName;
184            $groupConfig = $this->config->$configSectionName ?? false;
185            if ($groupConfig) {
186                $group = [
187                    'group' => $groupName,
188                    'max_age' => $groupConfig->max_age ?? 86400, // one day
189                    'endpoints' => [],
190                ];
191                foreach ($groupConfig->endpoints_url ?? [] as $url) {
192                    $group['endpoints'][] = [
193                        'url' => $url,
194                    ];
195                }
196                $groupsText[] = json_encode($group, JSON_UNESCAPED_SLASHES);
197            }
198        }
199
200        if (!$groupsText) {
201            return null;
202        }
203        $reportToHeader->setFieldValue(implode(', ', $groupsText));
204        return $reportToHeader;
205    }
206
207    /**
208     * Create NEL (Network Error Logging) header based on given configuration
209     *
210     * @return ?GenericHeader
211     */
212    public function getNetworkErrorLoggingHeader()
213    {
214        $nelHeader = new \Laminas\Http\Header\GenericHeader();
215        $nelHeader->setFieldName('NEL');
216        $nelData = [];
217
218        $nelConfig = $this->config->NetworkErrorLogging;
219        if ($reportTo = $nelConfig['report_to'] ?? null) {
220            $nelData['report_to'] = $reportTo;
221        } else {
222            return null;
223        }
224        $nelData['max_age'] = $nelConfig['max_age'] ?? 86400; // one day
225        if (isset($nelConfig['include_subdomains'])) {
226            $nelData['include_subdomains'] = (bool)$nelConfig['include_subdomains'];
227        }
228        if (isset($nelConfig['failure_fraction'])) {
229            $nelData['failure_fraction'] = (float)$nelConfig['failure_fraction'];
230        }
231
232        $nelText = json_encode($nelData, JSON_UNESCAPED_SLASHES);
233        $nelHeader->setFieldValue($nelText);
234        return $nelHeader;
235    }
236}