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

SecurityHeaderMiddleware::disableCSP()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
c 3
b 0
f 0
nc 2
nop 0
dl 0
loc 10
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_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
        $headersToSend = $headersConfig['global'];
87
88
        if (!$this->disableReporting() && $this->config()->get('use_report_to')) {
89
            $this->addReportToHeader($headersToSend);
90
        }
91
92
        // Update CSP header.
93
        if (array_key_exists('Content-Security-Policy', $headersToSend)) {
94
            $header = 'Content-Security-Policy';
95
            $headerValue = $headersToSend['Content-Security-Policy'];
96
97
            // Set report only mode if appropriate.
98
            if ($this->isCSPReportingOnly()) {
99
                unset($headersToSend['Content-Security-Policy']);
100
                $header = 'Content-Security-Policy-Report-Only';
101
            }
102
103
            // Update CSP header value.
104
            $headersToSend[$header] = $this->updateCspHeader($headerValue);
105
106
            // Disable CSP
107
            if ($this->disableCSP()) {
108
                unset($headersToSend['Content-Security-Policy']);
109
            }
110
        }
111
        $this->extend('updateHeaders', $headersToSend, $request);
112
113
        // Add headers to response.
114
        foreach ($headersToSend as $header => $value) {
115
            if (empty($value)) {
116
                continue;
117
            }
118
            $value = preg_replace('/\v/', '', $value);
119
            $this->extend('updateHeader', $header, $value, $request);
120
            if ($value) {
121
                $response->addHeader($header, $value);
122
            }
123
        }
124
125
        return $response;
126
    }
127
128
    /**
129
     * Return true if the Disable CSP is checked
130
     *
131
     * @return boolean
132
     */
133
    public function disableCSP()
134
    {
135
        if (
136
            self::isCSPReportingAvailable() &&
137
            SiteConfig::current_site_config()->CSPReportingOnly == SecurityHeaderSiteconfigExtension::CSP_DISABLE
138
        ) {
139
            return true;
140
        }
141
142
        return false;
143
    }
144
145
    /**
146
     * Return true if the Disable reporting is checked
147
     *
148
     * The CMS setting can disable reporting even if the 'enable_reporting' is true
149
     *
150
     * @return boolean
151
     */
152
    public function disableReporting()
153
    {
154
        if (self::isCSPReportingAvailable()) {
155
            return SiteConfig::current_site_config()->CSPReportingOnly ==
156
            SecurityHeaderSiteconfigExtension::CSP_WITHOUT_REPORTING ||
157
            !$this->config()->get('enable_reporting');
158
        }
159
160
        return false;
161
    }
162
163
    /**
164
     * Returns true if the Content-Security-Policy-Report-Only header should be used.
165
     *
166
     * @return boolean
167
     */
168
    public function isCSPReportingOnly()
169
    {
170
        if (
171
            self::isCSPReportingAvailable() &&
172
            SiteConfig::current_site_config()->CSPReportingOnly == SecurityHeaderSiteconfigExtension::CSP_REPORTING_ONLY
173
        ) {
174
            return true;
175
        }
176
177
        return false;
178
    }
179
180
    protected function getReportURI()
181
    {
182
        return Director::absoluteURL($this->config()->get('report_uri'));
183
    }
184
185
    protected function getIncludeSubdomains()
186
    {
187
        return $this->config()->get('report_to_subdomains');
188
    }
189
190
    protected function getReportToGroup()
191
    {
192
        return $this->config()->get('report_to_group');
193
    }
194
195
    protected function getReportURIDirective()
196
    {
197
        return "report-uri {$this->getReportURI()}";
198
    }
199
200
    protected function getReportToDirective()
201
    {
202
        return "report-to {$this->getReportToGroup()}";
203
    }
204
205
    protected function addReportToHeader(&$headers)
206
    {
207
        if (array_key_exists('Report-To', $headers)) {
208
            $headers['Report-To'] = $headers['Report-To'] . ',' . $this->getReportToHeader();
209
        } else {
210
            $headers['Report-To'] = $this->getReportToHeader();
211
        }
212
    }
213
214
    protected function getReportToHeader()
215
    {
216
        $header = [
217
            'group' => $this->getReportToGroup(),
218
            'max_age' => 1800,
219
            'endpoints' => [[
220
                'url' => $this->getReportURI(),
221
            ],],
222
            'include_subdomains' => $this->getIncludeSubdomains(),
223
        ];
224
        return json_encode($header);
225
    }
226
227
    protected function updateCspHeader($cspHeader)
228
    {
229
        if (!$this->disableReporting()) {
230
            // Add or update report-uri directive.
231
            if (strpos($cspHeader, 'report-uri')) {
232
                $cspHeader = str_replace('report-uri', $this->getReportURIDirective(), $cspHeader);
233
            } else {
234
                $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportURIDirective()};";
235
            }
236
237
            // Add report-to directive.
238
            // Note that unlike report-uri, only the first endpoint is used if multiple are declared.
239
            if ($this->config()->get('use_report_to')) {
240
                if (strpos($cspHeader, 'report-to') === false) {
241
                    $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportToDirective()};";
242
                }
243
            }
244
        }
245
246
        return $cspHeader;
247
    }
248
249
    /**
250
     * Is the CSPReportingOnly field safe to read.
251
     *
252
     * If the module is installed and the codebase is flushed before the database has been built,
253
     * accessing SiteConfig causes an error.
254
     *
255
     * @return boolean
256
     */
257
    private static function isCSPReportingAvailable()
258
    {
259
        // Cached true value.
260
        if (self::$is_csp_reporting_safe) {
261
            return self::$is_csp_reporting_safe;
262
        }
263
264
        // Check if all tables and fields required for the class exist in the database.
265
        $requiredClasses = ClassInfo::dataClassesFor(SiteConfig::class);
266
        $schema = DataObject::getSchema();
267
        foreach (array_unique($requiredClasses) as $required) {
268
            // Skip test classes, as not all test classes are scaffolded at once
269
            if (is_a($required, TestOnly::class, true)) {
270
                continue;
271
            }
272
273
            // if any of the tables aren't created in the database
274
            $table = $schema->tableName($required);
275
            if (!ClassInfo::hasTable($table)) {
276
                return false;
277
            }
278
279
            // if any of the tables don't have any fields mapped as table columns
280
            $dbFields = DB::field_list($table);
281
            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...
282
                return false;
283
            }
284
285
            // if any of the tables are missing fields mapped as table columns
286
            $objFields = $schema->databaseFields($required, false);
287
            $missingFields = array_diff_key($objFields, $dbFields);
288
            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...
289
                return false;
290
            }
291
        }
292
293
        self::$is_csp_reporting_safe = true;
294
295
        return true;
296
    }
297
}
298