Passed
Pull Request — master (#6)
by
unknown
02:42
created

SecurityHeaderMiddleware::disableReporting()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 3
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 7
rs 10
1
<?php
2
3
namespace Signify\Middleware;
4
5
use Signify\Extensions\SecurityHeaderSiteconfigExtension;
6
use SilverStripe\Control\Director;
7
use SilverStripe\Control\HTTPRequest;
8
use SilverStripe\Control\Middleware\HTTPMiddleware;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\Core\Extensible;
11
use SilverStripe\Core\Config\Configurable;
12
use SilverStripe\Dev\TestOnly;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\DataObject;
15
use SilverStripe\SiteConfig\SiteConfig;
16
17
class SecurityHeaderMiddleware implements HTTPMiddleware
18
{
19
    use Configurable;
20
    use Extensible;
21
22
    /**
23
     * An array of HTTP headers.
24
     * @config
25
     * @var array
26
     */
27
    private static $headers = [
0 ignored issues
show
introduced by
The private property $headers is not used, and could be removed.
Loading history...
28
        'global' => array(),
29
    ];
30
31
    /**
32
     * Whether to automatically add the CMS report endpoint to the CSP config.
33
     * @config
34
     * @var string
35
     */
36
    private static $enable_reporting = true;
0 ignored issues
show
introduced by
The private property $enable_reporting is not used, and could be removed.
Loading history...
37
38
    /**
39
     * The URI to report CSP violations to.
40
     * See routes.yml
41
     * @config
42
     * @var string
43
     */
44
    private static $report_uri = 'cspviolations/report';
0 ignored issues
show
introduced by
The private property $report_uri is not used, and could be removed.
Loading history...
45
46
    /**
47
     * Whether to use the report-to header and CSP directive.
48
     * @config
49
     * @var string
50
     */
51
    private static $use_report_to = false;
0 ignored issues
show
introduced by
The private property $use_report_to is not used, and could be removed.
Loading history...
52
53
    /**
54
     * Whether subdomains should report to the same endpoint.
55
     * @config
56
     * @var string
57
     */
58
    private static $report_to_subdomains = false;
0 ignored issues
show
introduced by
The private property $report_to_subdomains is not used, and could be removed.
Loading history...
59
60
    /**
61
     * The group name for the report-to CSP directive.
62
     * @config
63
     * @var string
64
     */
65
    private static $report_to_group = 'signify-csp-violation';
0 ignored issues
show
introduced by
The private property $report_to_group is not used, and could be removed.
Loading history...
66
67
    /**
68
     * Can isCSPReportingOnly be used safely.
69
     *
70
     * This is not a config option.
71
     *
72
     * @var boolean
73
     */
74
    private static $is_csp_reporting_only_safe = false;
75
76
77
    public function process(HTTPRequest $request, callable $delegate)
78
    {
79
        $response = $delegate($request);
80
81
        $headersConfig = (array) $this->config()->get('headers');
82
        if (empty($headersConfig['global'])) {
83
            return $response;
84
        }
85
86
        // Disable CSP
87
        if ($this->disableCSP()) {
88
            return $response;
89
        }
90
91
        $headersToSend = $headersConfig['global'];
92
93
        // Disable reporting
94
        if ($this->disableReporting()) {
95
            $this->config()->set('enable_reporting', false);
96
        }
97
98
        if ($this->config()->get('enable_reporting') && $this->config()->get('use_report_to')) {
99
            $this->addReportToHeader($headersToSend);
100
        }
101
        // Update CSP header.
102
        if (array_key_exists('Content-Security-Policy', $headersToSend)) {
103
            $header = 'Content-Security-Policy';
104
            $headerValue = $headersToSend['Content-Security-Policy'];
105
            // Set report only mode if appropriate.
106
            if ($this->isCSPReportingOnly()) {
107
                unset($headersToSend['Content-Security-Policy']);
108
                $header = 'Content-Security-Policy-Report-Only';
109
            }
110
            // Update CSP header value.
111
            $headersToSend[$header] = $this->updateCspHeader($headerValue);
112
        }
113
        $this->extend('updateHeaders', $headersToSend, $request);
114
115
        // Add headers to response.
116
        foreach ($headersToSend as $header => $value) {
117
            if (empty($value)) {
118
                continue;
119
            }
120
            $value = preg_replace('/\v/', '', $value);
121
            $this->extend('updateHeader', $header, $value, $request);
122
            if ($value) {
123
                $response->addHeader($header, $value);
124
            }
125
        }
126
127
        return $response;
128
    }
129
130
    /**
131
     * Return true if the Disable CSP is checked
132
     *
133
     * @return boolean
134
     */
135
    public function disableCSP()
136
    {
137
        if (SiteConfig::current_site_config()->CSPReportingOnly == '3'){
138
            return true;
139
        }
140
141
        return false;
142
    }
143
144
    /**
145
     * Return true if the Disable reporting is checked
146
     *
147
     * @return boolean
148
     */
149
    public function disableReporting()
150
    {
151
        if (SiteConfig::current_site_config()->CSPReportingOnly == '2'){
152
            return true;
153
        }
154
155
        return false;
156
    }
157
158
    /**
159
     * Returns true if the Content-Security-Policy-Report-Only header should be used.
160
     *
161
     * @return boolean
162
     */
163
    public function isCSPReportingOnly()
164
    {
165
        if (self::isCSPReportingOnlyAvailable() && SiteConfig::current_site_config()->CSPReportingOnly == '1') {
166
            return true;
167
        }
168
169
        return false;
170
    }
171
172
    protected function getReportURI()
173
    {
174
        return Director::absoluteURL($this->config()->get('report_uri'));
175
    }
176
177
    protected function getIncludeSubdomains()
178
    {
179
        return $this->config()->get('report_to_subdomains');
180
    }
181
182
    protected function getReportToGroup()
183
    {
184
        return $this->config()->get('report_to_group');
185
    }
186
187
    protected function getReportURIDirective()
188
    {
189
        return "report-uri {$this->getReportURI()}";
190
    }
191
192
    protected function getReportToDirective()
193
    {
194
        return "report-to {$this->getReportToGroup()}";
195
    }
196
197
    protected function addReportToHeader(&$headers)
198
    {
199
        if (array_key_exists('Report-To', $headers)) {
200
            $headers['Report-To'] = $headers['Report-To'] . ',' . $this->getReportToHeader();
201
        } else {
202
            $headers['Report-To'] = $this->getReportToHeader();
203
        }
204
    }
205
206
    protected function getReportToHeader()
207
    {
208
        $header = [
209
            'group' => $this->getReportToGroup(),
210
            'max_age' => 1800,
211
            'endpoints' => [[
212
                'url' => $this->getReportURI(),
213
            ],],
214
            'include_subdomains' => $this->getIncludeSubdomains(),
215
        ];
216
        return json_encode($header);
217
    }
218
219
    protected function updateCspHeader($cspHeader)
220
    {
221
        if ($this->config()->get('enable_reporting')) {
222
            // Add or update report-uri directive.
223
            if (strpos($cspHeader, 'report-uri')) {
224
                $cspHeader = str_replace('report-uri', $this->getReportURIDirective(), $cspHeader);
225
            } else {
226
                $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportURIDirective()};";
227
            }
228
229
            // Add report-to directive.
230
            // Note that unlike report-uri, only the first endpoint is used if multiple are declared.
231
            if ($this->config()->get('use_report_to')) {
232
                if (strpos($cspHeader, 'report-to') === false) {
233
                    $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportToDirective()};";
234
                }
235
            }
236
        }
237
238
        return $cspHeader;
239
    }
240
241
    /**
242
     * Is the CSPReportingOnly field safe to read.
243
     *
244
     * If the module is installed and the codebase is flushed before the database has been built,
245
     * accessing SiteConfig causes an error.
246
     *
247
     * @return boolean
248
     */
249
    private static function isCSPReportingOnlyAvailable()
250
    {
251
        // Cached true value.
252
        if (self::$is_csp_reporting_only_safe) {
253
            return self::$is_csp_reporting_only_safe;
254
        }
255
256
        // Check if all tables and fields required for the class exist in the database.
257
        $requiredClasses = ClassInfo::dataClassesFor(SiteConfig::class);
258
        $schema = DataObject::getSchema();
259
        foreach (array_unique($requiredClasses) as $required) {
260
            // Skip test classes, as not all test classes are scaffolded at once
261
            if (is_a($required, TestOnly::class, true)) {
262
                continue;
263
            }
264
265
            // if any of the tables aren't created in the database
266
            $table = $schema->tableName($required);
267
            if (!ClassInfo::hasTable($table)) {
268
                return false;
269
            }
270
271
            // if any of the tables don't have any fields mapped as table columns
272
            $dbFields = DB::field_list($table);
273
            if (!$dbFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $dbFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
274
                return false;
275
            }
276
277
            // if any of the tables are missing fields mapped as table columns
278
            $objFields = $schema->databaseFields($required, false);
279
            $missingFields = array_diff_key($objFields, $dbFields);
280
            if ($missingFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $missingFields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
281
                return false;
282
            }
283
        }
284
285
        self::$is_csp_reporting_only_safe = true;
286
287
        return true;
288
    }
289
}
290