Passed
Pull Request — master (#1)
by
unknown
08:16
created

SecurityHeaderMiddleware   A

Complexity

Total Complexity 31

Size/Duplication

Total Lines 227
Duplicated Lines 0 %

Importance

Changes 9
Bugs 4 Features 0
Metric Value
eloc 78
c 9
b 4
f 0
dl 0
loc 227
rs 9.92
wmc 31

11 Methods

Rating   Name   Duplication   Size   Complexity  
B isCSPReportingOnlyAvailable() 0 39 7
A getIncludeSubdomains() 0 3 1
A getReportToGroup() 0 3 1
A updateCspHeader() 0 20 5
A getReportURIDirective() 0 3 1
A isCSPReportingOnly() 0 3 2
A getReportURI() 0 3 1
A getReportToHeader() 0 11 1
A addReportToHeader() 0 6 2
A getReportToDirective() 0 3 1
B process() 0 40 9
1
<?php
2
namespace Signify\Middleware;
3
4
use Signify\Extensions\SecurityHeaderSiteconfigExtension;
5
use SilverStripe\Control\Director;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\Middleware\HTTPMiddleware;
8
use SilverStripe\Core\ClassInfo;
9
use SilverStripe\Core\Extensible;
10
use SilverStripe\Core\Config\Configurable;
11
use SilverStripe\Dev\TestOnly;
12
use SilverStripe\ORM\DB;
13
use SilverStripe\ORM\DataObject;
14
use SilverStripe\SiteConfig\SiteConfig;
15
16
class SecurityHeaderMiddleware implements HTTPMiddleware
17
{
18
    use Configurable;
19
    use Extensible;
20
21
    /**
22
     * An array of HTTP headers.
23
     * @config
24
     * @var array
25
     */
26
    private static $headers = [
0 ignored issues
show
introduced by
The private property $headers is not used, and could be removed.
Loading history...
27
        'global' => array(),
28
    ];
29
30
    /**
31
     * Whether to automatically add the CMS report endpoint to the CSP config.
32
     * @config
33
     * @var string
34
     */
35
    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...
36
37
    /**
38
     * The URI to report CSP violations to.
39
     * See routes.yml
40
     * @config
41
     * @var string
42
     */
43
    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...
44
45
    /**
46
     * Whether to use the report-to header and CSP directive.
47
     * @config
48
     * @var string
49
     */
50
    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...
51
52
    /**
53
     * Whether subdomains should report to the same endpoint.
54
     * @config
55
     * @var string
56
     */
57
    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...
58
59
    /**
60
     * The group name for the report-to CSP directive.
61
     * @config
62
     * @var string
63
     */
64
    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...
65
66
    /**
67
     * Can isCSPReportingOnly be used safely.
68
     *
69
     * This is not a config option.
70
     *
71
     * @var boolean
72
     */
73
    private static $is_csp_reporting_only_safe = false;
74
75
76
    public function process(HTTPRequest $request, callable $delegate)
77
    {
78
        $response = $delegate($request);
79
80
        $headersConfig = (array) $this->config()->get('headers');
81
        if (empty($headersConfig['global'])) {
82
            return $response;
83
        }
84
85
        $headersToSend = $headersConfig['global'];
86
        if ($this->config()->get('enable_reporting') && $this->config()->get('use_report_to')) {
87
            $this->addReportToHeader($headersToSend);
88
        }
89
        // Update CSP header.
90
        if (array_key_exists('Content-Security-Policy', $headersToSend)) {
91
            $header = 'Content-Security-Policy';
92
            $headerValue = $headersToSend['Content-Security-Policy'];
93
            // Set report only mode if appropriate.
94
            if ($this->isCSPReportingOnly()) {
95
                unset($headersToSend['Content-Security-Policy']);
96
                $header = 'Content-Security-Policy-Report-Only';
97
            }
98
            // Update CSP header value.
99
            $headersToSend[$header] = $this->updateCspHeader($headerValue);
100
        }
101
        $this->extend('updateHeaders', $headersToSend, $request);
102
103
        // Add headers to response.
104
        foreach ($headersToSend as $header => $value) {
105
            if (empty($value)) {
106
                continue;
107
            }
108
            $value = preg_replace('/\v/', '', $value);
109
            $this->extend('updateHeader', $header, $value, $request);
110
            if ($value) {
111
                $response->addHeader($header, $value);
112
            }
113
        }
114
115
        return $response;
116
    }
117
118
    /**
119
     * Returns true if the Content-Security-Policy-Report-Only header should be used.
120
     *
121
     * @return boolean
122
     */
123
    public function isCSPReportingOnly()
124
    {
125
        return self::isCSPReportingOnlyAvailable() ? SiteConfig::current_site_config()->CSPReportingOnly : false;
0 ignored issues
show
Bug Best Practice introduced by
The method Signify\Middleware\Secur...eportingOnlyAvailable() is not static, but was called statically. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

125
        return self::/** @scrutinizer ignore-call */ isCSPReportingOnlyAvailable() ? SiteConfig::current_site_config()->CSPReportingOnly : false;
Loading history...
126
    }
127
128
    protected function getReportURI()
129
    {
130
        return Director::absoluteURL($this->config()->get('report_uri'));
131
    }
132
133
    protected function getIncludeSubdomains()
134
    {
135
        return $this->config()->get('report_to_subdomains');
136
    }
137
138
    protected function getReportToGroup()
139
    {
140
        return $this->config()->get('report_to_group');
141
    }
142
143
    protected function getReportURIDirective()
144
    {
145
        return "report-uri {$this->getReportURI()}";
146
    }
147
148
    protected function getReportToDirective()
149
    {
150
        return "report-to {$this->getReportToGroup()}";
151
    }
152
153
    protected function addReportToHeader(&$headers)
154
    {
155
        if (array_key_exists('Report-To', $headers)) {
156
            $headers['Report-To'] = $headers['Report-To'] . ',' . $this->getReportToHeader();
157
        } else {
158
            $headers['Report-To'] = $this->getReportToHeader();
159
        }
160
    }
161
162
    protected function getReportToHeader()
163
    {
164
        $header = [
165
            'group' => $this->getReportToGroup(),
166
            'max_age' => 1800,
167
            'endpoints' => [[
168
                'url' => $this->getReportURI(),
169
            ],],
170
            'include_subdomains' => $this->getIncludeSubdomains(),
171
        ];
172
        return json_encode($header);
173
    }
174
175
    protected function updateCspHeader($cspHeader)
176
    {
177
        if ($this->config()->get('enable_reporting')) {
178
            // Add or update report-uri directive.
179
            if (strpos($cspHeader, 'report-uri')) {
180
                $cspHeader = str_replace('report-uri', $this->getReportURIDirective(), $cspHeader);
181
            } else {
182
                $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportURIDirective()};";
183
            }
184
185
            // Add report-to directive.
186
            // Note that unlike report-uri, only the first endpoint is used if multiple are declared.
187
            if ($this->config()->get('use_report_to')) {
188
                if (strpos($cspHeader, 'report-to') === false) {
189
                    $cspHeader = rtrim($cspHeader, ';') . "; {$this->getReportToDirective()};";
190
                }
191
            }
192
        }
193
194
        return $cspHeader;
195
    }
196
197
    /**
198
     * Is the CSPReportingOnly field safe to read.
199
     *
200
     * If the module is installed and the codebase is flushed before the database has been built, accessing SiteConfig causes an error.
201
     *
202
     * @return boolean
203
     */
204
    private function isCSPReportingOnlyAvailable()
205
    {
206
        // Cached true value.
207
        if (self::$is_csp_reporting_only_safe) {
208
            return self::$is_csp_reporting_only_safe;
209
        }
210
211
        // Basically SiteConfig, but it could be an injected class.
212
        $classes = ClassInfo::classesWithExtension(SecurityHeaderSiteconfigExtension::class);
213
214
        // Check if all tables and fields required for the class exist in the database.
215
        $requiredClasses = [];
216
        foreach ($classes as $class) {
217
            $requiredClasses += ClassInfo::dataClassesFor($class);
218
        }
219
220
        $schema = DataObject::getSchema();
221
        foreach ($requiredClasses as $required) {
222
            // Skip test classes, as not all test classes are scaffolded at once
223
            if (is_a($required, TestOnly::class, true)) {
224
                continue;
225
            }
226
227
            // if any of the tables aren't created in the database
228
            $table = $schema->tableName($required);
229
            if (!ClassInfo::hasTable($table)) {
230
                return false;
231
            }
232
233
            // if any of the tables don't have all fields mapped as table columns
234
            $dbFields = DB::field_list($table);
235
            if (!isset($dbFields['CSPReportingOnly'])) {
236
                return false;
237
            }
238
        }
239
240
        self::$is_csp_reporting_only_safe = true;
241
242
        return true;
243
    }
244
}
245