1*0579c2f8SAndreas Gohr<?php 2*0579c2f8SAndreas Gohr 3*0579c2f8SAndreas Gohr/** 4*0579c2f8SAndreas Gohr * Tests for the ACL enforcement around media uploads. 5*0579c2f8SAndreas Gohr * 6*0579c2f8SAndreas Gohr * media_save() trusts the authorization level supplied by its caller so that 7*0579c2f8SAndreas Gohr * delegated saves (for example the bureaucracy plugin's "runas" uploads) keep 8*0579c2f8SAndreas Gohr * working. The public upload endpoints media_upload() and media_upload_xhr() 9*0579c2f8SAndreas Gohr * must therefore authorize the resolved target id themselves, because the 10*0579c2f8SAndreas Gohr * requested media id may contain namespace separators pointing into a 11*0579c2f8SAndreas Gohr * namespace other than the one the upload form was opened for. 12*0579c2f8SAndreas Gohr */ 13*0579c2f8SAndreas Gohrclass media_upload_test extends DokuWikiTest { 14*0579c2f8SAndreas Gohr 15*0579c2f8SAndreas Gohr public function setUp(): void { 16*0579c2f8SAndreas Gohr parent::setUp(); 17*0579c2f8SAndreas Gohr 18*0579c2f8SAndreas Gohr global $conf, $AUTH_ACL, $USERINFO, $MSG; 19*0579c2f8SAndreas Gohr 20*0579c2f8SAndreas Gohr $conf['useacl'] = 1; 21*0579c2f8SAndreas Gohr $_SERVER['REMOTE_USER'] = 'john'; 22*0579c2f8SAndreas Gohr $USERINFO['grps'] = ['user']; 23*0579c2f8SAndreas Gohr $MSG = []; 24*0579c2f8SAndreas Gohr 25*0579c2f8SAndreas Gohr // upload allowed in public:* and public:sub:*, but denied in the 26*0579c2f8SAndreas Gohr // sibling child namespace public:private:* 27*0579c2f8SAndreas Gohr $AUTH_ACL = [ 28*0579c2f8SAndreas Gohr '* @ALL 0', 29*0579c2f8SAndreas Gohr 'public:* @user 8', // AUTH_UPLOAD 30*0579c2f8SAndreas Gohr 'public:sub:* @user 8', // AUTH_UPLOAD 31*0579c2f8SAndreas Gohr 'public:private:* @user 0', // AUTH_NONE 32*0579c2f8SAndreas Gohr ]; 33*0579c2f8SAndreas Gohr } 34*0579c2f8SAndreas Gohr 35*0579c2f8SAndreas Gohr /** 36*0579c2f8SAndreas Gohr * Provide a fresh temporary copy of a valid PNG. 37*0579c2f8SAndreas Gohr * 38*0579c2f8SAndreas Gohr * @return string path to the temporary file 39*0579c2f8SAndreas Gohr */ 40*0579c2f8SAndreas Gohr protected function tmpImage() { 41*0579c2f8SAndreas Gohr global $conf; 42*0579c2f8SAndreas Gohr $orig = mediaFN('wiki:dokuwiki-128.png'); 43*0579c2f8SAndreas Gohr $tmp = $conf['tmpdir'] . '/media_upload_test_' . md5($orig . microtime()) . '.png'; 44*0579c2f8SAndreas Gohr copy($orig, $tmp); 45*0579c2f8SAndreas Gohr return $tmp; 46*0579c2f8SAndreas Gohr } 47*0579c2f8SAndreas Gohr 48*0579c2f8SAndreas Gohr /** 49*0579c2f8SAndreas Gohr * Simulate a media_upload() request for the given namespace and media id. 50*0579c2f8SAndreas Gohr * 51*0579c2f8SAndreas Gohr * @param string $ns the namespace the upload form was opened for 52*0579c2f8SAndreas Gohr * @param string $mediaid the requested (possibly nested) media id 53*0579c2f8SAndreas Gohr * @return false|string the media_upload() return value 54*0579c2f8SAndreas Gohr */ 55*0579c2f8SAndreas Gohr protected function upload($ns, $mediaid) { 56*0579c2f8SAndreas Gohr global $MSG; 57*0579c2f8SAndreas Gohr $MSG = []; 58*0579c2f8SAndreas Gohr 59*0579c2f8SAndreas Gohr $_POST['mediaid'] = $mediaid; 60*0579c2f8SAndreas Gohr $_POST['ow'] = ''; 61*0579c2f8SAndreas Gohr $_REQUEST['sectok'] = getSecurityToken(); 62*0579c2f8SAndreas Gohr 63*0579c2f8SAndreas Gohr $file = [ 64*0579c2f8SAndreas Gohr 'name' => noNS($mediaid), 65*0579c2f8SAndreas Gohr 'tmp_name' => $this->tmpImage(), 66*0579c2f8SAndreas Gohr 'error' => 0, 67*0579c2f8SAndreas Gohr 'size' => 100, 68*0579c2f8SAndreas Gohr ]; 69*0579c2f8SAndreas Gohr 70*0579c2f8SAndreas Gohr return media_upload($ns, AUTH_UPLOAD, $file); 71*0579c2f8SAndreas Gohr } 72*0579c2f8SAndreas Gohr 73*0579c2f8SAndreas Gohr /** 74*0579c2f8SAndreas Gohr * Collect the messages emitted during the last request. 75*0579c2f8SAndreas Gohr * 76*0579c2f8SAndreas Gohr * @return string[] 77*0579c2f8SAndreas Gohr */ 78*0579c2f8SAndreas Gohr protected function messages() { 79*0579c2f8SAndreas Gohr global $MSG; 80*0579c2f8SAndreas Gohr return array_map(static fn($m) => $m['msg'], $MSG ?? []); 81*0579c2f8SAndreas Gohr } 82*0579c2f8SAndreas Gohr 83*0579c2f8SAndreas Gohr /** 84*0579c2f8SAndreas Gohr * A nested media id must be authorized against its own namespace, so an 85*0579c2f8SAndreas Gohr * upload into an ACL-denied child namespace is rejected even though the 86*0579c2f8SAndreas Gohr * form was opened for a permitted parent namespace. 87*0579c2f8SAndreas Gohr */ 88*0579c2f8SAndreas Gohr public function test_upload_rejects_denied_child_namespace() { 89*0579c2f8SAndreas Gohr $res = $this->upload('public', 'private:target.png'); 90*0579c2f8SAndreas Gohr 91*0579c2f8SAndreas Gohr $this->assertFalse($res); 92*0579c2f8SAndreas Gohr $this->assertContains( 93*0579c2f8SAndreas Gohr "You don't have permissions to upload files.", 94*0579c2f8SAndreas Gohr $this->messages(), 95*0579c2f8SAndreas Gohr 'the upload must be denied by the ACL check' 96*0579c2f8SAndreas Gohr ); 97*0579c2f8SAndreas Gohr $this->assertFileDoesNotExist(mediaFN('public:private:target.png')); 98*0579c2f8SAndreas Gohr } 99*0579c2f8SAndreas Gohr 100*0579c2f8SAndreas Gohr /** 101*0579c2f8SAndreas Gohr * A nested media id into a permitted namespace must pass the ACL check. 102*0579c2f8SAndreas Gohr * 103*0579c2f8SAndreas Gohr * The actual move fails in the test environment (is_uploaded_file()), so we 104*0579c2f8SAndreas Gohr * only assert that the request got past authorization. 105*0579c2f8SAndreas Gohr */ 106*0579c2f8SAndreas Gohr public function test_upload_allows_permitted_child_namespace() { 107*0579c2f8SAndreas Gohr $res = $this->upload('public', 'sub:target.png'); 108*0579c2f8SAndreas Gohr 109*0579c2f8SAndreas Gohr $this->assertFalse($res); 110*0579c2f8SAndreas Gohr $this->assertNotContains( 111*0579c2f8SAndreas Gohr "You don't have permissions to upload files.", 112*0579c2f8SAndreas Gohr $this->messages(), 113*0579c2f8SAndreas Gohr 'the upload must pass the ACL check for a permitted target' 114*0579c2f8SAndreas Gohr ); 115*0579c2f8SAndreas Gohr } 116*0579c2f8SAndreas Gohr 117*0579c2f8SAndreas Gohr /** 118*0579c2f8SAndreas Gohr * media_save() must trust the authorization level supplied by the caller, 119*0579c2f8SAndreas Gohr * so a privileged (delegated) save succeeds even when the current session 120*0579c2f8SAndreas Gohr * user has no access to the target namespace. 121*0579c2f8SAndreas Gohr */ 122*0579c2f8SAndreas Gohr public function test_save_trusts_supplied_auth() { 123*0579c2f8SAndreas Gohr $id = 'public:private:delegated.png'; 124*0579c2f8SAndreas Gohr $res = media_save( 125*0579c2f8SAndreas Gohr ['name' => $this->tmpImage(), 'mime' => 'image/png', 'ext' => 'png'], 126*0579c2f8SAndreas Gohr $id, 127*0579c2f8SAndreas Gohr false, 128*0579c2f8SAndreas Gohr AUTH_UPLOAD, 129*0579c2f8SAndreas Gohr 'copy' 130*0579c2f8SAndreas Gohr ); 131*0579c2f8SAndreas Gohr 132*0579c2f8SAndreas Gohr $this->assertSame($id, $res); 133*0579c2f8SAndreas Gohr $this->assertFileExists(mediaFN($id)); 134*0579c2f8SAndreas Gohr } 135*0579c2f8SAndreas Gohr 136*0579c2f8SAndreas Gohr /** 137*0579c2f8SAndreas Gohr * media_save() must still reject a caller that supplies an insufficient 138*0579c2f8SAndreas Gohr * authorization level. 139*0579c2f8SAndreas Gohr */ 140*0579c2f8SAndreas Gohr public function test_save_enforces_supplied_auth() { 141*0579c2f8SAndreas Gohr $id = 'public:target.png'; 142*0579c2f8SAndreas Gohr $res = media_save( 143*0579c2f8SAndreas Gohr ['name' => $this->tmpImage(), 'mime' => 'image/png', 'ext' => 'png'], 144*0579c2f8SAndreas Gohr $id, 145*0579c2f8SAndreas Gohr false, 146*0579c2f8SAndreas Gohr AUTH_READ, 147*0579c2f8SAndreas Gohr 'copy' 148*0579c2f8SAndreas Gohr ); 149*0579c2f8SAndreas Gohr 150*0579c2f8SAndreas Gohr $this->assertIsArray($res); 151*0579c2f8SAndreas Gohr $this->assertSame(-1, $res[1]); 152*0579c2f8SAndreas Gohr $this->assertFileDoesNotExist(mediaFN($id)); 153*0579c2f8SAndreas Gohr } 154*0579c2f8SAndreas Gohr} 155