Completed
Push — master ( afecc8...a1ff37 )
by Guy
13s queued 10s
created

SecurityHeaderMiddleware::getReportToHeader()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 7
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 11
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
        $headersToSend = $headersConfig['global'];
87
        if ($this->config()->get('enable_reporting') && $this->config()->get('use_report_to')) {
88
            $this->addReportToHeader($headersToSend);
89
        }
90
        // Update CSP header.
91
        if (array_key_exists('Content-Security-Policy', $headersToSend)) {
92
            $header = 'Content-Security-Policy';
93
            $headerValue = $headersToSend['Content-Security-Policy'];
94
            // Set report only mode if appropriate.
95
            if ($this->isCSPReportingOnly()) {
96
                unset($headersToSend['Content-Security-Policy']);
97
                $header = 'Content-Security-Policy-Report-Only';
98
            }
99
            // Update CSP header value.
100
            $headersToSend[$header] = $this->updateCspHeader($headerValue);
101
        }
102
        $this->extend('updateHeaders', $headersToSend, $request);
103
104
        // Add headers to response.
105
        foreach ($headersToSend as $header => $value) {
106
            if (empty($value)) {
107
                continue;
108
            }
109
            $value = preg_replace('/\v/', '', $value);
110
            $this->extend('updateHeader', $header, $value, $request);
111
            if ($value) {
112
                $response->addHeader($header, $value);
113
            }
114
        }
115
116
        return $response;
117
    }
118
119
    /**
120
     * Returns true if the Content-Security-Policy-Report-Only header should be used.
121
     *
122
     * @return boolean
123
     */
124
    public function isCSPReportingOnly()
125
    {
126
        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

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