Issues (40)

src/Controllers/CSPViolationsController.php (3 issues)

1
<?php
2
3
namespace Signify\Controllers;
4
5
use SilverStripe\Control\Controller;
6
use SilverStripe\Control\HTTPRequest;
7
use SilverStripe\Control\Director;
8
use Signify\Models\CSPViolation;
9
use Signify\Models\CSPDocument;
10
use SilverStripe\CMS\Model\SiteTree;
11
use SilverStripe\CMS\Controllers\RootURLController;
12
use SilverStripe\ORM\FieldType\DBField;
13
use SilverStripe\ORM\FieldType\DBDatetime;
14
use Signify\Middleware\SecurityHeaderMiddleware;
15
16
class CSPViolationsController extends Controller
17
{
18
    public const REPORT_TIME = 'ReportedTime';
19
20
    public const DISPOSITION = 'Disposition';
21
22
    public const BLOCKED_URI = 'BlockedURI';
23
24
    public const EFFECTIVE_DIRECTIVE = 'EffectiveDirective';
25
26
    public const DOCUMENT_URI = 'URI';
27
28
    public const REPORT_DIRECTIVE = 'ReportDirective';
29
30
31
    public function index(HTTPRequest $request)
32
    {
33
        if (!$request->isPOST() || !$this->isSameOrigin($request) || !$this->isReport($request)) {
34
            return $this->httpError(400);
35
        }
36
37
        // Depending on which directive was used to generate the report, the format will be slightly different.
38
        // We must do some pre-processing on the report to normalise the data.
39
        $json = json_decode($request->getBody(), true);
40
        if (isset($json['csp-report'])) {
41
            // This report was sent as a result of the "report-uri" directive.
42
            $report = $json['csp-report'];
43
            $report[self::REPORT_TIME] = DBDatetime::now()->getValue();
44
            $report[self::REPORT_DIRECTIVE] = 'report-uri';
45
            $this->processReport($report);
46
        } else {
47
            // This report was sent as a result of the "report-to" directive.
48
            // There may be multiple reports in the one request.
49
            foreach ($json as $reportWrapper) {
50
                if ($reportWrapper['type'] == 'csp-violation') {
51
                    $report = $reportWrapper['body'];
52
                    // 'age' is the number of milliseconds since the report was generated.
53
                    $report[self::REPORT_TIME] = DBField::create_field(
54
                        'Datetime',
55
                        time() - ($reportWrapper['age'] / 1000)
56
                    )->getValue();
57
                    $report[self::REPORT_DIRECTIVE] = 'report-to';
58
                    $this->processReport($report);
59
                }
60
            }
61
        }
62
    }
63
64
    /**
65
     * Process a Content Security Policy violation report.
66
     * Creates or updates the relevant CSPViolation object.
67
     * @param array $cspReport
68
     */
69
    public function processReport($cspReport)
70
    {
71
        $violation = $this->getOrCreateViolation($cspReport);
72
        $this->setDocument($cspReport, $violation);
73
        $violation->Violations++;
0 ignored issues
show
Bug Best Practice introduced by
The property Violations does not exist on Signify\Models\CSPViolation. Since you implemented __get, consider adding a @property annotation.
Loading history...
74
        $reportTime = $this->getDataForAttribute($cspReport, self::REPORT_TIME);
75
        if ($violation->{self::REPORT_TIME} === null || $violation->{self::REPORT_TIME} < $reportTime) {
76
            $violation->{self::REPORT_TIME} = $reportTime;
77
        }
78
        $violation->write();
79
    }
80
81
    /**
82
     * If this violation has been previously reported, get that violation object.  Otherwise, create a new one.
83
     * @param array $cspReport
84
     * @return CSPViolation
85
     */
86
    protected function getOrCreateViolation($cspReport)
87
    {
88
        $violationData = [
89
            self::DISPOSITION => $this->getDataForAttribute($cspReport, self::DISPOSITION),
90
            self::BLOCKED_URI => $this->getDataForAttribute($cspReport, self::BLOCKED_URI),
91
            self::EFFECTIVE_DIRECTIVE => $this->getDataForAttribute($cspReport, self::EFFECTIVE_DIRECTIVE),
92
        ];
93
94
        $violation = CSPViolation::get()->filter($violationData)->first();
95
        if (!$violation) {
96
            $violationData['Violations'] = 0;
97
            $violation = CSPViolation::create($violationData);
98
        }
99
100
        return $violation;
101
    }
102
103
    /**
104
     * Set the document-uri for a given violation based on the report.
105
     * @param array $cspReport
106
     * @param CSPViolation $violation
107
     */
108
    protected function setDocument($cspReport, $violation)
109
    {
110
        $documentURI = $this->getDataForAttribute($cspReport, self::DOCUMENT_URI);
111
        // If the document is already added to this violation, no need to re-add it.
112
        if ($violation->Documents()->find('URI', $documentURI)) {
0 ignored issues
show
The method Documents() does not exist on Signify\Models\CSPViolation. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

112
        if ($violation->/** @scrutinizer ignore-call */ Documents()->find('URI', $documentURI)) {
Loading history...
113
            return;
114
        }
115
116
        $documentData = [
117
            self::DOCUMENT_URI => $documentURI,
118
        ];
119
        $document = CSPDocument::get()->filter($documentData)->first();
120
121
        if (!$document) {
122
            $document = CSPDocument::create($documentData);
123
            $siteTreeLink = $documentURI;
124
            if (!Director::makeRelative($siteTreeLink)) {
125
                // SiteTree::get_by_link returns null, see https://github.com/silverstripe/silverstripe-cms/issues/2580
126
                $siteTreeLink = RootURLController::get_homepage_link();
127
            }
128
            if ($siteTree = SiteTree::get_by_link($siteTreeLink)) {
129
                $document->SiteTreeID = $siteTree->ID;
130
            }
131
            $document->write();
132
        }
133
134
        $violation->Documents()->add($document);
135
    }
136
137
    /**
138
     * Get the data from the report for a given attribute.
139
     * The reports generated by the report-to and report-uri directives have different keys.
140
     * @param array $cspReport
141
     * @param string $attribute
142
     * @return mixed
143
     */
144
    protected function getDataForAttribute($cspReport, $attribute)
145
    {
146
        if ($cspReport[self::REPORT_DIRECTIVE] == 'report-uri') {
147
            switch ($attribute) {
148
                case self::REPORT_TIME:
149
                    return $this->normaliseDateTime($cspReport[self::REPORT_TIME]);
150
                case self::DISPOSITION:
151
                    if (!empty($cspReport['disposition'])) {
152
                        return $cspReport['disposition'];
153
                    } else {
154
                        // Firefox doesn't report the disposition.
155
                        return 'unknown';
156
                    }
157
                    return ;
0 ignored issues
show
return is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
158
                case self::BLOCKED_URI:
159
                    return $cspReport['blocked-uri'];
160
                case self::EFFECTIVE_DIRECTIVE:
161
                    if (!empty($cspReport['effective-directive'])) {
162
                        return $cspReport['effective-directive'];
163
                    } else {
164
                        // Firefox doesn't report the effective directive.
165
                        return $cspReport['violated-directive'];
166
                    }
167
                case self::DOCUMENT_URI:
168
                    return $cspReport['document-uri'];
169
            }
170
        } elseif ($cspReport[self::REPORT_DIRECTIVE] == 'report-to') {
171
            switch ($attribute) {
172
                case self::REPORT_TIME:
173
                    return $this->normaliseDateTime($cspReport[self::REPORT_TIME]);
174
                case self::DISPOSITION:
175
                    return $cspReport['disposition'];
176
                case self::BLOCKED_URI:
177
                    return $cspReport['blockedURL'];
178
                case self::EFFECTIVE_DIRECTIVE:
179
                    return $cspReport['effectiveDirective'];
180
                case self::DOCUMENT_URI:
181
                    return $cspReport['documentURL'];
182
            }
183
        }
184
185
        // This should never be hit...
186
        return null;
187
    }
188
189
    /**
190
     * Removes the seconds from a datetime string for easier comparisons.
191
     *
192
     * The datetime-local Chrome implementation doesn't include seconds, so it's easiest to just not include
193
     * seconds at all so that exact matches work as expected.
194
     *
195
     * @var string $datetime
196
     */
197
    protected function normaliseDateTime($datetime)
198
    {
199
        return preg_replace('/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}):\d{2}/', '$1', $datetime);
200
    }
201
202
    /**
203
     * If the origin header is set, return true if it is the same as the current absolute base URL.
204
     *
205
     * @param HTTPRequest $request
206
     * @return boolean
207
     */
208
    protected function isSameOrigin(HTTPRequest $request)
209
    {
210
        $origin = $request->getHeader('origin');
211
212
        // The origin header may not be set for report-to requests, so null must be considered sameorigin.
213
        if (SecurityHeaderMiddleware::config()->get('use_report_to') && $origin === null) {
214
            return true;
215
        }
216
217
        // If not using report-to, or the origin header is set, only allow same origin requests.
218
        return $origin == Director::protocolAndHost();
219
    }
220
221
    /**
222
     * Returns true if the content-type of the request is a valid CSP report value.
223
     * @param HTTPRequest $request
224
     * @return boolean
225
     */
226
    protected function isReport(HTTPRequest $request)
227
    {
228
        return in_array($request->getHeader('content-type'), [
229
            'application/csp-report', // from report-uri directive
230
            'application/reports+json', // from report-to directive
231
            'application/json', // fallback
232
        ]);
233
    }
234
}
235