RequestSanitizerTest.php 15 KB

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