1<?php
2
3namespace Drupal\Tests\Core\Security;
4
5use Drupal\Core\Security\RequestSanitizer;
6use Drupal\Tests\UnitTestCase;
7use Symfony\Component\HttpFoundation\Request;
8
9/**
10 * Tests RequestSanitizer class.
11 *
12 * @coversDefaultClass \Drupal\Core\Security\RequestSanitizer
13 * @runTestsInSeparateProcesses
14 * @preserveGlobalState disabled
15 * @group Security
16 */
17class RequestSanitizerTest extends UnitTestCase {
18
19  /**
20   * Log of errors triggered during sanitization.
21   *
22   * @var array
23   */
24  protected $errors;
25
26  /**
27   * {@inheritdoc}
28   */
29  protected function setUp() {
30    parent::setUp();
31    $this->errors = [];
32    set_error_handler([$this, "errorHandler"]);
33  }
34
35  /**
36   * Tests RequestSanitizer class.
37   *
38   * @param \Symfony\Component\HttpFoundation\Request $request
39   *   The request to sanitize.
40   * @param array $expected
41   *   An array of expected request parameters after sanitization. The possible
42   *   keys are 'cookies', 'query', 'request' which correspond to the parameter
43   *   bags names on the request object. These values are also used to test the
44   *   PHP globals post sanitization.
45   * @param array|null $expected_errors
46   *   An array of expected errors. If set to NULL then error logging is
47   *   disabled.
48   * @param array $whitelist
49   *   An array of keys to whitelist and not sanitize.
50   *
51   * @dataProvider providerTestRequestSanitization
52   */
53  public function testRequestSanitization(Request $request, array $expected = [], array $expected_errors = NULL, array $whitelist = []) {
54    // Set up globals.
55    $_GET = $request->query->all();
56    $_POST = $request->request->all();
57    $_COOKIE = $request->cookies->all();
58    $_REQUEST = array_merge($request->query->all(), $request->request->all());
59    $request->server->set('QUERY_STRING', http_build_query($request->query->all()));
60    $_SERVER['QUERY_STRING'] = $request->server->get('QUERY_STRING');
61
62    $request = RequestSanitizer::sanitize($request, $whitelist, is_null($expected_errors) ? FALSE : TRUE);
63
64    // Normalize the expected data.
65    $expected += ['cookies' => [], 'query' => [], 'request' => []];
66    $expected_query_string = http_build_query($expected['query']);
67
68    // Test the request.
69    $this->assertEquals($expected['cookies'], $request->cookies->all());
70    $this->assertEquals($expected['query'], $request->query->all());
71    $this->assertEquals($expected['request'], $request->request->all());
72    $this->assertTrue($request->attributes->get(RequestSanitizer::SANITIZED));
73    // The request object normalizes the request query string.
74    $this->assertEquals(Request::normalizeQueryString($expected_query_string), $request->getQueryString());
75
76    // Test PHP globals.
77    $this->assertEquals($expected['cookies'], $_COOKIE);
78    $this->assertEquals($expected['query'], $_GET);
79    $this->assertEquals($expected['request'], $_POST);
80    $expected_request = array_merge($expected['query'], $expected['request']);
81    $this->assertEquals($expected_request, $_REQUEST);
82    $this->assertEquals($expected_query_string, $_SERVER['QUERY_STRING']);
83
84    // Ensure any expected errors have been triggered.
85    if (!empty($expected_errors)) {
86      foreach ($expected_errors as $expected_error) {
87        $this->assertError($expected_error, E_USER_NOTICE);
88      }
89    }
90    else {
91      $this->assertEquals([], $this->errors);
92    }
93  }
94
95  /**
96   * Data provider for testRequestSanitization.
97   *
98   * @return array
99   */
100  public function providerTestRequestSanitization() {
101    $tests = [];
102
103    $request = new Request(['q' => 'index.php']);
104    $tests['no sanitization GET'] = [$request, ['query' => ['q' => 'index.php']]];
105
106    $request = new Request([], ['field' => 'value']);
107    $tests['no sanitization POST'] = [$request, ['request' => ['field' => 'value']]];
108
109    $request = new Request([], [], [], ['key' => 'value']);
110    $tests['no sanitization COOKIE'] = [$request, ['cookies' => ['key' => 'value']]];
111
112    $request = new Request(['q' => 'index.php'], ['field' => 'value'], [], ['key' => 'value']);
113    $tests['no sanitization GET, POST, COOKIE'] = [$request, ['query' => ['q' => 'index.php'], 'request' => ['field' => 'value'], 'cookies' => ['key' => 'value']]];
114
115    $request = new Request(['q' => 'index.php']);
116    $tests['no sanitization GET log'] = [$request, ['query' => ['q' => 'index.php']], []];
117
118    $request = new Request([], ['field' => 'value']);
119    $tests['no sanitization POST log'] = [$request, ['request' => ['field' => 'value']], []];
120
121    $request = new Request([], [], [], ['key' => 'value']);
122    $tests['no sanitization COOKIE log'] = [$request, ['cookies' => ['key' => 'value']], []];
123
124    $request = new Request(['#q' => 'index.php']);
125    $tests['sanitization GET'] = [$request];
126
127    $request = new Request([], ['#field' => 'value']);
128    $tests['sanitization POST'] = [$request];
129
130    $request = new Request([], [], [], ['#key' => 'value']);
131    $tests['sanitization COOKIE'] = [$request];
132
133    $request = new Request(['#q' => 'index.php'], ['#field' => 'value'], [], ['#key' => 'value']);
134    $tests['sanitization GET, POST, COOKIE'] = [$request];
135
136    $request = new Request(['#q' => 'index.php']);
137    $tests['sanitization GET log'] = [$request, [], ['Potentially unsafe keys removed from query string parameters (GET): #q']];
138
139    $request = new Request([], ['#field' => 'value']);
140    $tests['sanitization POST log'] = [$request, [], ['Potentially unsafe keys removed from request body parameters (POST): #field']];
141
142    $request = new Request([], [], [], ['#key' => 'value']);
143    $tests['sanitization COOKIE log'] = [$request, [], ['Potentially unsafe keys removed from cookie parameters: #key']];
144
145    $request = new Request(['#q' => 'index.php'], ['#field' => 'value'], [], ['#key' => 'value']);
146    $tests['sanitization GET, POST, COOKIE log'] = [$request, [], ['Potentially unsafe keys removed from query string parameters (GET): #q', 'Potentially unsafe keys removed from request body parameters (POST): #field', 'Potentially unsafe keys removed from cookie parameters: #key']];
147
148    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo']]);
149    $tests['recursive sanitization log'] = [$request, ['query' => ['q' => 'index.php', 'foo' => []]], ['Potentially unsafe keys removed from query string parameters (GET): #bar']];
150
151    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo']]);
152    $tests['recursive no sanitization whitelist'] = [$request, ['query' => ['q' => 'index.php', 'foo' => ['#bar' => 'foo']]], [], ['#bar']];
153
154    $request = new Request([], ['#field' => 'value']);
155    $tests['no sanitization POST whitelist'] = [$request, ['request' => ['#field' => 'value']], [], ['#field']];
156
157    $request = new Request(['q' => 'index.php', 'foo' => ['#bar' => 'foo', '#foo' => 'bar']]);
158    $tests['recursive multiple sanitization log'] = [$request, ['query' => ['q' => 'index.php', 'foo' => []]], ['Potentially unsafe keys removed from query string parameters (GET): #bar, #foo']];
159
160    $request = new Request(['#q' => 'index.php']);
161    $request->attributes->set(RequestSanitizer::SANITIZED, TRUE);
162    $tests['already sanitized request'] = [$request, ['query' => ['#q' => 'index.php']]];
163
164    $request = new Request(['destination' => 'whatever?%23test=value']);
165    $tests['destination removal GET'] = [$request];
166
167    $request = new Request([], ['destination' => 'whatever?%23test=value']);
168    $tests['destination removal POST'] = [$request];
169
170    $request = new Request([], [], [], ['destination' => 'whatever?%23test=value']);
171    $tests['destination removal COOKIE'] = [$request];
172
173    $request = new Request(['destination' => 'whatever?%23test=value']);
174    $tests['destination removal GET log'] = [$request, [], ['Potentially unsafe destination removed from query parameter bag because it contained the following keys: #test']];
175
176    $request = new Request([], ['destination' => 'whatever?%23test=value']);
177    $tests['destination removal POST log'] = [$request, [], ['Potentially unsafe destination removed from request parameter bag because it contained the following keys: #test']];
178
179    $request = new Request([], [], [], ['destination' => 'whatever?%23test=value']);
180    $tests['destination removal COOKIE log'] = [$request, [], ['Potentially unsafe destination removed from cookies parameter bag because it contained the following keys: #test']];
181
182    $request = new Request(['destination' => 'whatever?q[%23test]=value']);
183    $tests['destination removal subkey'] = [$request];
184
185    $request = new Request(['destination' => 'whatever?q[%23test]=value']);
186    $tests['destination whitelist'] = [$request, ['query' => ['destination' => 'whatever?q[%23test]=value']], [], ['#test']];
187
188    $request = new Request(['destination' => "whatever?\x00bar=base&%23test=value"]);
189    $tests['destination removal zero byte'] = [$request];
190
191    $request = new Request(['destination' => 'whatever?q=value']);
192    $tests['destination kept'] = [$request, ['query' => ['destination' => 'whatever?q=value']]];
193
194    $request = new Request(['destination' => 'whatever']);
195    $tests['destination no query'] = [$request, ['query' => ['destination' => 'whatever']]];
196
197    return $tests;
198  }
199
200  /**
201   * Tests acceptable destinations are not removed from GET requests.
202   *
203   * @param string $destination
204   *   The destination string to test.
205   *
206   * @dataProvider providerTestAcceptableDestinations
207   */
208  public function testAcceptableDestinationGet($destination) {
209    // Set up a GET request.
210    $request = $this->createRequestForTesting(['destination' => $destination]);
211
212    $request = RequestSanitizer::sanitize($request, [], TRUE);
213
214    $this->assertSame($destination, $request->query->get('destination', NULL));
215    $this->assertNull($request->request->get('destination', NULL));
216    $this->assertSame($destination, $_GET['destination']);
217    $this->assertSame($destination, $_REQUEST['destination']);
218    $this->assertArrayNotHasKey('destination', $_POST);
219    $this->assertEquals([], $this->errors);
220  }
221
222  /**
223   * Tests unacceptable destinations are removed from GET requests.
224   *
225   * @param string $destination
226   *   The destination string to test.
227   *
228   * @dataProvider providerTestSanitizedDestinations
229   */
230  public function testSanitizedDestinationGet($destination) {
231    // Set up a GET request.
232    $request = $this->createRequestForTesting(['destination' => $destination]);
233
234    $request = RequestSanitizer::sanitize($request, [], TRUE);
235
236    $this->assertNull($request->request->get('destination', NULL));
237    $this->assertNull($request->query->get('destination', NULL));
238    $this->assertArrayNotHasKey('destination', $_POST);
239    $this->assertArrayNotHasKey('destination', $_REQUEST);
240    $this->assertArrayNotHasKey('destination', $_GET);
241    $this->assertError('Potentially unsafe destination removed from query parameter bag because it points to an external URL.', E_USER_NOTICE);
242  }
243
244  /**
245   * Tests acceptable destinations are not removed from POST requests.
246   *
247   * @param string $destination
248   *   The destination string to test.
249   *
250   * @dataProvider providerTestAcceptableDestinations
251   */
252  public function testAcceptableDestinationPost($destination) {
253    // Set up a POST request.
254    $request = $this->createRequestForTesting([], ['destination' => $destination]);
255
256    $request = RequestSanitizer::sanitize($request, [], TRUE);
257
258    $this->assertSame($destination, $request->request->get('destination', NULL));
259    $this->assertNull($request->query->get('destination', NULL));
260    $this->assertSame($destination, $_POST['destination']);
261    $this->assertSame($destination, $_REQUEST['destination']);
262    $this->assertArrayNotHasKey('destination', $_GET);
263    $this->assertEquals([], $this->errors);
264  }
265
266  /**
267   * Tests unacceptable destinations are removed from GET requests.
268   *
269   * @param string $destination
270   *   The destination string to test.
271   *
272   * @dataProvider providerTestSanitizedDestinations
273   */
274  public function testSanitizedDestinationPost($destination) {
275    // Set up a POST request.
276    $request = $this->createRequestForTesting([], ['destination' => $destination]);
277
278    $request = RequestSanitizer::sanitize($request, [], TRUE);
279
280    $this->assertNull($request->request->get('destination', NULL));
281    $this->assertNull($request->query->get('destination', NULL));
282    $this->assertArrayNotHasKey('destination', $_POST);
283    $this->assertArrayNotHasKey('destination', $_REQUEST);
284    $this->assertArrayNotHasKey('destination', $_GET);
285    $this->assertError('Potentially unsafe destination removed from request parameter bag because it points to an external URL.', E_USER_NOTICE);
286  }
287
288  /**
289   * Creates a request and sets PHP globals for testing.
290   *
291   * @param array $query
292   *   (optional) The GET parameters.
293   * @param array $request
294   *   (optional) The POST parameters.
295   *
296   * @return \Symfony\Component\HttpFoundation\Request
297   *   The request object.
298   */
299  protected function createRequestForTesting(array $query = [], array $request = []) {
300    $request = new Request($query, $request);
301
302    // Set up globals.
303    $_GET = $request->query->all();
304    $_POST = $request->request->all();
305    $_COOKIE = $request->cookies->all();
306    $_REQUEST = array_merge($request->query->all(), $request->request->all());
307    $request->server->set('QUERY_STRING', http_build_query($request->query->all()));
308    $_SERVER['QUERY_STRING'] = $request->server->get('QUERY_STRING');
309    return $request;
310  }
311
312  /**
313   * Data provider for testing acceptable destinations.
314   */
315  public function providerTestAcceptableDestinations() {
316    $data = [];
317    // Standard internal example node path is present in the 'destination'
318    // parameter.
319    $data[] = ['node'];
320    // Internal path with one leading slash is allowed.
321    $data[] = ['/example.com'];
322    // Internal URL using a colon is allowed.
323    $data[] = ['example:test'];
324    // Javascript URL is allowed because it is treated as an internal URL.
325    $data[] = ['javascript:alert(0)'];
326    return $data;
327  }
328
329  /**
330   * Data provider for testing sanitized destinations.
331   */
332  public function providerTestSanitizedDestinations() {
333    $data = [];
334    // External URL without scheme is not allowed.
335    $data[] = ['//example.com/test'];
336    // External URL is not allowed.
337    $data[] = ['http://example.com'];
338    return $data;
339  }
340
341  /**
342   * Catches and logs errors to $this->errors.
343   *
344   * @param int $errno
345   *   The severity level of the error.
346   * @param string $errstr
347   *   The error message.
348   */
349  public function errorHandler($errno, $errstr) {
350    $this->errors[] = compact('errno', 'errstr');
351  }
352
353  /**
354   * Asserts that the expected error has been logged.
355   *
356   * @param string $errstr
357   *   The error message.
358   * @param int $errno
359   *   The severity level of the error.
360   */
361  protected function assertError($errstr, $errno) {
362    foreach ($this->errors as $error) {
363      if ($error['errstr'] === $errstr && $error['errno'] === $errno) {
364        return;
365      }
366    }
367    $this->fail("Error with level $errno and message '$errstr' not found in " . var_export($this->errors, TRUE));
368  }
369
370}
371