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