1<?php 2 3/** 4 * @group ajax 5 */ 6class ajax_requests_test extends DokuWikiTest { 7 8 /** 9 * DataProvider for the builtin Ajax calls 10 * 11 * @return array 12 */ 13 public function defaultCalls() { 14 return [ 15 // TODO: better logic and DOM walks 16 // Call | POST | regexp pattern to match 17 [ 'linkwiz', ['q' => ''], '/^<div class="odd type_d/' ], 18 [ 'suggestions', ['q' => ''], null ], 19 [ 'lock', ['id' => ''], null ], 20 [ 'draftdel', ['id' => ''], null ], 21 [ 'medians', ['ns' => 'some:ns'], null ], 22 [ 'medialist', ['ns' => '', 'recent' => '', 'do' => ''], null ], 23 [ 'mediadetails', ['image' => ''], null ], 24 [ 'mediadiff', ['image' => ''], null ], 25 [ 'mediaupload', ['mediaid' => '', 'qqfile' => '' ], null ], // $_FILES 26 [ 'index', ['idx' => ''], null ], 27 [ 'linkwiz', ['q' => ''], null ], 28 ]; 29 } 30 31 /** 32 * @dataProvider defaultCalls 33 * @param string $call 34 * @param array $post 35 * @param string $regexp 36 */ 37 public function test_defaultCallsExist($call, $post, $regexp) { 38 39 $request = new TestRequest(); 40 $response = $request->post(['call'=> $call]+$post, '/lib/exe/ajax.php'); 41 $this->assertNotEquals("AJAX call '$call' unknown!\n", $response->getContent()); 42 43 if (!empty($regexp)) { 44 $this->assertMatchesRegularExpression($regexp, $response->getContent()); 45 } 46 } 47 48 /** 49 * callMediaupload must normalize the namespace with cleanID() before it is used. 50 * 51 * regression test for XSS reflection and passing unclened data to the ACL check 52 */ 53 public function test_mediaupload_reflects_cleaned_namespace() { 54 $request = new TestRequest(); 55 $response = $request->post( 56 ['call' => 'mediaupload', 'ns' => 'Foo"><script>x</script>'], 57 '/lib/exe/ajax.php' 58 ); 59 60 $result = json_decode($response->getContent(), true); 61 $this->assertIsArray($result); 62 $this->assertSame( 63 'foo_script_x_script', 64 $result['ns'], 65 'the raw namespace must be cleaned before it is used' 66 ); 67 } 68 69 /** 70 * Compute a security token for the given user, matching what same-process code (the 71 * request) computes for the same user and the shared test session, without leaking the 72 * temporary REMOTE_USER into the global state other tests rely on. 73 * 74 * @param string $user 75 * @return string 76 */ 77 protected function validTokenFor($user) { 78 global $INPUT; 79 $oldServer = $_SERVER; 80 $oldInput = $INPUT; 81 $_SERVER['REMOTE_USER'] = $user; 82 $INPUT = new \dokuwiki\Input\Input(); 83 $token = getSecurityToken(); 84 $_SERVER = $oldServer; 85 $INPUT = $oldInput; 86 return $token; 87 } 88 89 /** 90 * The happy path: a logged in user with a valid token takes the page lock and the 91 * posted text is stored as a retrievable draft. 92 * 93 * Doubles as the "valid token is accepted" case: if the CSRF gate wrongly rejected a 94 * valid token the lock would stay '0' and this test would fail. 95 */ 96 public function test_lock_takesLockAndSavesDraft() { 97 $id = 'lock:happy'; 98 $text = 'some draft text'; 99 100 $request = new TestRequest(); 101 $request->setServer('REMOTE_USER', 'testuser'); 102 $response = $request->post( 103 ['call' => 'lock', 'id' => $id, 'sectok' => $this->validTokenFor('testuser'), 'wikitext' => $text], 104 '/lib/exe/ajax.php' 105 ); 106 107 $result = json_decode($response->getContent(), true); 108 $this->assertIsArray($result); 109 $this->assertSame([], $result['errors']); 110 $this->assertEquals('1', $result['lock'], 'the lock must be taken'); 111 $this->assertNotEmpty($result['draft'], 'a draft-saved message must be returned'); 112 113 // the lock is actually held on disk by the user 114 $this->assertFileExists(wikiLockFN($id)); 115 $this->assertEquals('testuser', io_readFile(wikiLockFN($id))); 116 117 // the draft is retrievable and round-trips the posted text 118 $draft = new \dokuwiki\Draft($id, 'testuser'); 119 $this->assertTrue($draft->isDraftAvailable()); 120 $this->assertStringContainsString($text, $draft->getDraftText()); 121 } 122 123 /** 124 * The lock call takes a page lock and writes a draft, so for a logged in user it 125 * must be protected against CSRF. A request carrying an invalid security token must 126 * be rejected before any lock is taken. 127 */ 128 public function test_lock_rejectsInvalidSecurityToken() { 129 $id = 'lock:reject'; 130 131 $request = new TestRequest(); 132 $request->setServer('REMOTE_USER', 'testuser'); 133 $response = $request->post( 134 ['call' => 'lock', 'id' => $id, 'sectok' => 'not-the-real-token', 'wikitext' => 'x'], 135 '/lib/exe/ajax.php' 136 ); 137 138 $result = json_decode($response->getContent(), true); 139 $this->assertIsArray($result); 140 $this->assertNotEmpty($result['errors'], 'an invalid security token must be rejected'); 141 $this->assertEquals('0', $result['lock'], 'no lock may be taken on a rejected request'); 142 $this->assertFileDoesNotExist(wikiLockFN($id), 'no lock file may be written on a rejected request'); 143 } 144 145 /** 146 * When the user lacks write permission the call refuses to lock or save a draft. 147 */ 148 public function test_lock_deniedWhenNotWritable() { 149 global $conf, $AUTH_ACL; 150 $id = 'lock:denied'; 151 152 $oldAcl = $AUTH_ACL; 153 $conf['useacl'] = 1; 154 $AUTH_ACL = ['* @ALL 0']; // deny everyone 155 156 try { 157 // anonymous: no security token needed, the write ACL is what must block this 158 $request = new TestRequest(); 159 $response = $request->post( 160 ['call' => 'lock', 'id' => $id, 'wikitext' => 'x'], 161 '/lib/exe/ajax.php' 162 ); 163 164 $result = json_decode($response->getContent(), true); 165 $this->assertIsArray($result); 166 $this->assertNotEmpty($result['errors'], 'a denied write must be reported'); 167 $this->assertEquals('0', $result['lock']); 168 $this->assertSame('', $result['draft'], 'no draft may be saved when not writable'); 169 $this->assertFileDoesNotExist(wikiLockFN($id)); 170 } finally { 171 $AUTH_ACL = $oldAcl; 172 } 173 } 174 175 /** 176 * A lock already held by someone else must not be reported as freshly taken and must 177 * be left untouched. 178 */ 179 public function test_lock_doesNotStealForeignLock() { 180 $id = 'lock:foreign'; 181 182 // someone else already holds the lock 183 io_saveFile(wikiLockFN($id), 'someoneelse'); 184 185 $request = new TestRequest(); 186 $request->setServer('REMOTE_USER', 'testuser'); 187 $response = $request->post( 188 ['call' => 'lock', 'id' => $id, 'sectok' => $this->validTokenFor('testuser')], 189 '/lib/exe/ajax.php' 190 ); 191 192 $result = json_decode($response->getContent(), true); 193 $this->assertIsArray($result); 194 $this->assertEquals('0', $result['lock'], 'a foreign lock must not be reported as freshly taken'); 195 $this->assertEquals('someoneelse', io_readFile(wikiLockFN($id)), 'the foreign lock must be left untouched'); 196 } 197 198 public function test_CallNotProvided() { 199 $request = new TestRequest(); 200 $response = $request->post([], '/lib/exe/ajax.php'); 201 $this->assertEquals('', $response->getContent()); 202 } 203 204 public function test_UnknownCall() { 205 $call = 'unknownCALL'; 206 $request = new TestRequest(); 207 $response = $request->post(['call'=> $call], '/lib/exe/ajax.php'); 208 $this->assertEquals("AJAX call '$call' unknown!\n", $response->getContent()); 209 } 210 211 212 public function test_EventOnUnknownCall() { 213 global $EVENT_HANDLER; 214 $call = 'unknownCALL'; 215 $request = new TestRequest(); 216 217 // referenced data from event hook 218 $hookTriggered = false; 219 $eventDataTriggered = ''; 220 $dataTriggered = ''; 221 $postTriggered = ''; 222 223 $hookTriggered_AFTER = false; 224 $eventDataTriggered_AFTER = ''; 225 $dataTriggered_AFTER = ''; 226 $postTriggered_AFTER = ''; 227 228 $EVENT_HANDLER->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', null, 229 function($event, $data) use (&$hookTriggered, &$dataTriggered, &$eventDataTriggered, &$postTriggered) { 230 /** @var Doku_Event $event */ 231 $hookTriggered = true; 232 $dataTriggered = $data; 233 $eventDataTriggered = $event->data; 234 $postTriggered = $GLOBALS['INPUT']->post->str('q'); 235 $event->preventDefault(); 236 $event->stopPropagation(); 237 echo "captured event BEFORE\n"; 238 }, 'some passed data' 239 ); 240 241 $EVENT_HANDLER->register_hook('AJAX_CALL_UNKNOWN', 'AFTER', null, 242 function($event, $data) use (&$hookTriggered_AFTER , &$dataTriggered_AFTER , &$eventDataTriggered_AFTER , &$postTriggered_AFTER ) { 243 /** @var Doku_Event $event */ 244 $hookTriggered_AFTER = true; 245 $dataTriggered_AFTER = $data; 246 $eventDataTriggered_AFTER = $event->data; 247 $postTriggered_AFTER = $GLOBALS['INPUT']->post->str('q'); 248 $event->preventDefault(); 249 $event->stopPropagation(); 250 echo "captured event AFTER"; 251 }, 'some passed data AFTER' 252 ); 253 254 255 $response = $request->post(['call'=> $call, 'q' => 'some-post-param'], '/lib/exe/ajax.php'); 256 257 // BEFORE 258 $this->assertEquals(true, $hookTriggered, 'Testing plugin did not trigger!'); 259 $this->assertEquals('some passed data', $dataTriggered); 260 $this->assertEquals($call, $eventDataTriggered, 'Must pass call name as event data'); 261 $this->assertEquals('some-post-param', $postTriggered); 262 263 // AFTER 264 $this->assertEquals(true, $hookTriggered_AFTER, 'Testing plugin did not trigger!'); 265 $this->assertEquals('some passed data AFTER', $dataTriggered_AFTER); 266 $this->assertEquals($call, $eventDataTriggered_AFTER, 'Must pass call name as event data'); 267 $this->assertEquals('some-post-param', $postTriggered_AFTER); 268 269 //output 270 $this->assertEquals("captured event BEFORE\ncaptured event AFTER", $response->getContent()); 271 272 } 273} 274