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
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
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 function fx() {
try {
doSomething();
return true;
}
catch (\Exception $e) {
return false;
}
return false;
}
In the above example, the last
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 |