| 1 | import test, { describe, before, after } from 'node:test'; |
| 2 | import { promisify } from 'util' |
| 3 | import { srpClientSequence } from '../src/srp' |
| 4 | import * as srp from 'tssrp6a' |
| 5 | import { createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statfsSync, statSync } from 'fs' |
| 6 | import { basename, dirname, join, resolve } from 'path' |
| 7 | import { exec } from 'child_process' |
| 8 | import _ from 'lodash' |
| 9 | import yaml from 'yaml' |
| 10 | import unzipper from 'unzipper' |
| 11 | import { findDefined, pathEncode, randomId, try_, tryJson, UPLOAD_TEMP_HASH, wait, waitFor } from '../src/cross' |
| 12 | import { httpStream, httpWithBody, parseHttpUrl, stream2string, XRequestOptions } from '../src/util-http' |
| 13 | import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream' |
| 14 | import { mkdir, rm, rename, writeFile, access } from 'fs/promises' |
| 15 | import { Readable } from 'stream' |
| 16 | import { XMLValidator } from 'fast-xml-parser' |
| 17 | import { BASIC_AUTHENTICATE_HEADER } from '../src/cross' |
| 18 | /* |
| 19 | import { PORT, srv } from '../src' |
| 20 | |
| 21 | process.chdir('..') |
| 22 | const appStarted = new Promise(resolve => |
| 23 | srv.on( 'app_started', resolve) ) |
| 24 | */ |
| 25 | |
| 26 | const username = 'rejetto' |
| 27 | const password = 'password' |
| 28 | const auth = `${username}:${password}` |
| 29 | const API = '/~/api/' |
| 30 | const ROOT = 'tests/' |
| 31 | const TEST_PORT = Number(yaml.parse(readFileSync(resolve(__dirname, 'config.yaml'), 'utf8')).port) |
| 32 | const BASE_URL = `http://[::1]:${TEST_PORT}` |
| 33 | const BASE_URL_127 = `http://127.0.0.1:${TEST_PORT}` |
| 34 | const UPLOAD_ROOT = '/for-admins/upload/' |
| 35 | // keep generated uploads under the directory reset by test runners |
| 36 | const UPLOAD_DISK_ROOT = resolve(__dirname, 'tmp') |
| 37 | const VIRTUAL_UPLOAD_ROOT = '/renameChild/' |
| 38 | const FUNNY_NAME = 'x%25#x' |
| 39 | const FUNNY_NAME_ENCODED = '/x%2525%23x' |
| 40 | const UPLOAD_DIR = 'temp' |
| 41 | const CANT_OVERWRITE_NAME = 'cant-overwrite' |
| 42 | const CANT_OVERWRITE_URI = `/for-admins/${CANT_OVERWRITE_NAME}/` |
| 43 | const UPLOAD_RELATIVE = `${UPLOAD_DIR}/gpl.png` |
| 44 | const UPLOAD_DEST = UPLOAD_ROOT + UPLOAD_RELATIVE |
| 45 | const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to saturate buffers |
| 46 | const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads |
| 47 | const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png') |
| 48 | const WEBDAV_UA = 'Microsoft-WebDAV-MiniRedir/10.0.22000' |
| 49 | const OFFICE_WEBDAV_UA = 'Microsoft Office Existence Discovery' |
| 50 | const TOKEN_HEADER = 'lock-token' |
| 51 | const WEBDAV_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?> |
| 52 | <lockinfo xmlns="DAV:"> |
| 53 | <lockscope><exclusive/></lockscope> |
| 54 | <locktype><write/></locktype> |
| 55 | </lockinfo>` |
| 56 | const WEBDAV_SHARED_LOCK_BODY = `<?xml version="1.0" encoding="utf-8"?> |
| 57 | <lockinfo xmlns="DAV:"> |
| 58 | <lockscope><shared/></lockscope> |
| 59 | <locktype><write/></locktype> |
| 60 | </lockinfo>` |
| 61 | const WEBDAV_PROPPATCH_BODY = `<?xml version="1.0" encoding="utf-8"?> |
| 62 | <D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:schemas-microsoft-com:"> |
| 63 | <D:set> |
| 64 | <D:prop> |
| 65 | <Z:Win32LastModifiedTime>Mon, 04 May 2026 10:00:00 GMT</Z:Win32LastModifiedTime> |
| 66 | <Z:Win32FileAttributes>00000020</Z:Win32FileAttributes> |
| 67 | <D:getlastmodified>Mon, 04 May 2026 10:00:00 GMT</D:getlastmodified> |
| 68 | </D:prop> |
| 69 | </D:set> |
| 70 | </D:propertyupdate>` |
| 71 | let defaultBaseUrl = BASE_URL |
| 72 | |
| 73 | const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout) |
| 74 | const srp6aNimbusRoutines = new srp.SRPRoutines(new srp.SRPParameters()) |
| 75 | |
| 76 | describe('basics', () => { |
| 77 | test('parseHttpUrl.path escapes invalid chars and keeps unresolved segments', () => { |
| 78 | const parsedPath = parseHttpUrl('https://example.com/a/../репо with space/%2e%2e/file').path |
| 79 | if (parsedPath !== '/a/../%D1%80%D0%B5%D0%BF%D0%BE%20with%20space/%2e%2e/file') |
| 80 | throw Error('unexpected path: ' + parsedPath) |
| 81 | }) |
| 82 | //before(async () => appStarted) |
| 83 | test('frontend', req('/', /<body>/, { headers: { accept: '*/*' } })) // workaround: 'accept' is necessary when running server-for-test-dev, still don't know why |
| 84 | test('force slash', req('/f1', 302, { noRedirect: true })) |
| 85 | test('list', reqList('/f1/', { inList:['f2/', 'page/'] })) |
| 86 | test('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' })) |
| 87 | test('search root', reqList('/', { inList:['cantListPage/'], outList:['cantListPage/page/'] }, { search:'page' })) |
| 88 | test('search.fifo order', async () => { |
| 89 | // deep search queues subdirectory jobs via makeQ; verify results come in FIFO order. |
| 90 | // tree: root/{d01..d10}/sub/ — with LIFO the sub/ entries reverse relative to parent order (tau≈-1). |
| 91 | const dir = resolve(__dirname, '_fifo_test') |
| 92 | const dirCount = 10 // well above dirQ parallelization (3) |
| 93 | for (let i = 1; i <= dirCount; i++) { |
| 94 | const name = `d${String(i).padStart(2, '0')}` |
| 95 | mkdirSync(join(dir, name, 'sub'), { recursive: true }) |
| 96 | } |
| 97 | try { |
| 98 | await reqList('/tests/_fifo_test', { |
| 99 | cb(data: any) { |
| 100 | const names: string[] = data.list.map((x: any) => x.n) |
| 101 | const parents = names.filter((n: string) => !n.includes('/')) |
| 102 | const subs = names.filter((n: string) => n.endsWith('sub/')).map((n: string) => n.split('/')[0]) |
| 103 | // Kendall's tau: +1 = same order (FIFO), -1 = reversed (LIFO) |
| 104 | let concordant = 0, discordant = 0 |
| 105 | for (let i = 0; i < parents.length; i++) |
| 106 | for (let j = i + 1; j < parents.length; j++) { |
| 107 | const d = subs.indexOf(parents[i]) - subs.indexOf(parents[j]) |
| 108 | if (d > 0) discordant++ |
| 109 | else if (d < 0) concordant++ |
| 110 | } |
| 111 | const tau = (concordant - discordant) / (concordant + discordant) |
| 112 | if (tau <= 0) |
| 113 | throw `search results not FIFO; tau=${tau.toFixed(2)}, parents: ${parents}, subs: ${subs}` |
| 114 | } |
| 115 | }, { search: '*' })() |
| 116 | } |
| 117 | finally { |
| 118 | rmSync(dir, { recursive: true, force: true }) |
| 119 | } |
| 120 | }) |
| 121 | test('download.mime', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' })) |
| 122 | test('download.disposition', req('/f1/f2/alfa.txt', (_data, res) => res.headers['content-disposition'].startsWith('inline; filename='))) |
| 123 | test('download.disposition quotes', { skip: process.platform === 'win32' }, async () => { |
| 124 | const name = '"quoted".txt' |
| 125 | const file = resolve(__dirname, name) |
| 126 | await writeFile(file, '') |
| 127 | await req('/tests/' + pathEncode(name), (_data, res) => res.headers['content-disposition'].includes('filename="\\"quoted\\".txt"'))() |
| 128 | .finally(() => rm(file)) |
| 129 | }) |
| 130 | test('download.not modified', async () => { |
| 131 | let lm = '' |
| 132 | await req('/f1/f2/alfa.txt', (_data, res) => lm = res.headers?.['last-modified'])() |
| 133 | if (!lm) |
| 134 | throw "last-modified" |
| 135 | await req('/f1/f2/alfa.txt', { status: 304, empty: true }, { headers: { 'If-Modified-Since': lm } })() |
| 136 | }) |
| 137 | test('download.if-range', async () => { |
| 138 | let etag = '' |
| 139 | await req('/f1/f2/alfa.txt', (_data, res) => etag = res.headers?.etag)() |
| 140 | if (!etag) |
| 141 | throw "missing etag" |
| 142 | await req('/f1/f2/alfa.txt', /a[^d]+$/, { headers: { Range: 'bytes=0-2', 'If-Range': etag } })() // only "abc" is expected |
| 143 | }) |
| 144 | test('download.partial', req('/f1/f2/alfa.txt', /a[^d]+$/, { headers: { Range: 'bytes=0-2' } })) // only "abc" is expected |
| 145 | test('bad range', req('/f1/f2/alfa.txt', 416, { headers: { Range: 'bytes=7-' } })) |
| 146 | test('bad range.inverted', req('/f1/f2/alfa.txt', 416, { headers: { Range: 'bytes=3-2' } })) |
| 147 | test('bad range.malformed', req('/f1/f2/alfa.txt', 400, { headers: { Range: 'bytes=abc-def' } })) |
| 148 | test('roots', req('/f2/alfa.txt', 200, { baseUrl: BASE_URL_127 })) // host 127.0.0.1 is rooted in /f1 |
| 149 | test('website', req('/f1/page/', { re:/This is a test/, mime:'text/html' })) |
| 150 | test('traversal', req('/f1/page/.%2e/.%2e/README.md', 404)) |
| 151 | test('traversal.double-encoded', req('/f1/page/%252e%252e/%252e%252e/README.md', 404)) |
| 152 | test('traversal.encoded-slash', req('/f1/page/%2e%2e%2f%2e%2e%2fREADME.md', 404)) |
| 153 | test('traversal.backslash', req('/f1/page/..%5c..%5cREADME.md', 404)) |
| 154 | test('traversal.to-admin', req('/f1/page/%2e%2e/%2e%2e/for-admins/alfa.txt', 404)) |
| 155 | test('traversal.mixed-dots', req('/f1/page/.%2e/%2e./README.md', 404)) |
| 156 | test('traversal.lang', async () => { |
| 157 | const pathNoExt = 'tmp-secret' |
| 158 | const fullPath = resolve(__dirname, pathNoExt + '.json') |
| 159 | await mkdir(dirname(fullPath), { recursive: true }) |
| 160 | try { |
| 161 | const marker = 'TRAVERSAL_READ' |
| 162 | await writeFile(fullPath, JSON.stringify({ translate: { 'Not found': marker } })) // translating Not found exposes the read through the fallback 404 page when GUI assets are absent |
| 163 | await req('/?lang=x/../../' + pathNoExt, data => !String(data).includes(marker), { |
| 164 | headers: { 'user-agent': 'Mozilla/5.0' }, |
| 165 | })() |
| 166 | } |
| 167 | finally { await rmAny(fullPath) } |
| 168 | }) |
| 169 | test('traversal.overlong-utf8', req('/f1/page/%c0%ae%c0%ae/%c0%ae%c0%ae/README.md', 404)) |
| 170 | test('bad url encoding', req('/f1/%E0%A4%A', 404)) |
| 171 | test('not-found.default page', req('/missing-default-404', /found<\/h1>/)) |
| 172 | test('not-found.custom page overrides default', () => |
| 173 | withCustomHtml({ 404: '<strong>custom 404 $MESSAGE</strong>' }, () => |
| 174 | req('/missing-custom-404', /^<strong>custom 404 Not found<\/strong>$/)()) ) |
| 175 | test('not-found.default page reverse proxy root', req('/missing-proxy-404', /href="\/prefix/, { headers: { 'x-forwarded-prefix': '/prefix' } })) |
| 176 | test('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' })) |
| 177 | test('name encoding', req(FUNNY_NAME_ENCODED, 200)) |
| 178 | test('name encoding list', reqList('/', { inList: [FUNNY_NAME] })) |
| 179 | test('name encoding search', reqList('/', { inList: [FUNNY_NAME] }, { search: FUNNY_NAME })) |
| 180 | test('basic listing escapes', async () => { |
| 181 | const name = '<img src=x onerror=alert(1)>.png' |
| 182 | const path = resolve(__dirname, name) |
| 183 | await writeFile(path, '') |
| 184 | try { |
| 185 | await req('/tests/?get=basic', { status: 200, cb: data => !String(data).includes(name) }, { |
| 186 | headers: { 'user-agent': 'Mozilla/5.0' }, |
| 187 | })() |
| 188 | } |
| 189 | finally { |
| 190 | await rm(path, { force: true }) |
| 191 | } |
| 192 | }) |
| 193 | test('folder list preserves encoded colon in prepend', req('/tests/C%3A/?get=list&folders=*', data => { |
| 194 | if (!String(data).includes('/tests/C%3A/gpl.png')) |
| 195 | throw Error('missing correctly encoded path in list: ' + data) |
| 196 | if (String(data).includes('/tests/C%253A/')) |
| 197 | throw Error('double encoded path in list: ' + data) |
| 198 | })) |
| 199 | test('folder list strips base_url root', req('/f1/f2/?get=list&folders=*', data => { |
| 200 | data = String(data) |
| 201 | if (!data.includes(`${BASE_URL_127}/f2/alfa.txt`)) |
| 202 | throw Error('missing base_url-rooted path in list: ' + data) |
| 203 | if (data.includes(`${BASE_URL_127}/f1/f2/alfa.txt`)) |
| 204 | throw Error('base_url root still present in list: ' + data) |
| 205 | })) |
| 206 | test('folder list ignores base_url outside its root', req('/tests/?get=list&folders=*', data => { |
| 207 | data = String(data) |
| 208 | if (!data.includes(`${BASE_URL}/tests/page/`)) |
| 209 | throw Error('missing request-host path in list: ' + data) |
| 210 | if (data.includes(BASE_URL_127)) |
| 211 | throw Error('base_url used outside its root: ' + data) |
| 212 | })) |
| 213 | |
| 214 | test('missing perm', reqList('/for-admins/', 401)) |
| 215 | test('missing perm.file', req('/for-admins/alfa.txt', 401)) |
| 216 | test('missing anti-csrf', reqApi('rename', { uri: '/f1', dest: 'x' }, 418, { headers: {} })) // overriding anti-csrf |
| 217 | test('missing anti-csrf.get mutation', req(API + 'add_account?username=csrf&password=x&admin=true', 418, { |
| 218 | headers: { 'user-agent': 'Mozilla/5.0' }, |
| 219 | jar: {}, |
| 220 | })) |
| 221 | test('malformed body', reqApi('rename', { uri: '/f1', dest: 'x' }, { status: 400 }, { |
| 222 | headers: { 'x-hfs-anti-csrf': '1', 'content-type': 'application/json' }, |
| 223 | body: '{' |
| 224 | })) |
| 225 | test('file_details.missing', reqApi('get_file_details', { uris: ['/missing'] }, noVisibleDetails)) |
| 226 | test('file_details.hidden', reqApi('get_file_details', { uris: ['/tests/config.yaml'] }, noVisibleDetails)) |
| 227 | test('file_details.for-admins', reqApi('get_file_details', { uris: ['/for-admins/alfa.txt'] }, noVisibleDetails)) |
| 228 | test('file_details.traversal', reqApi('get_file_details', { uris: ['/f1/%2e%2e/for-admins/alfa.txt'] }, noVisibleDetails)) |
| 229 | test('file_list.traversal', reqApi('get_file_list', { uri: '/f1/%2e%2e/for-admins' }, 404)) |
| 230 | test('file_list.bad encoding', reqApi('get_file_list', { uri: '/f1/%E0%A4%A' }, 404)) |
| 231 | test('forbidden list', req('/cantListPage/page/', 403)) |
| 232 | test('forbidden list.api', reqList('/cantListPage/page/', 403)) |
| 233 | test('forbidden list.admin flag', reqApi('get_file_list', { uri: '/for-admins/', admin: true }, 401)) |
| 234 | test('forbidden list.cant see', reqList('/cantListPage/', { outList:['page/'] })) |
| 235 | test('forbidden list.but readable file', req('/cantListPage/page/gpl.png', 200)) |
| 236 | test('forbidden list.alternative method', reqList('/cantListPageAlt/page/', 403)) |
| 237 | test('forbidden list.match **', req('/cantListPageAlt/page/gpl.png', 401)) |
| 238 | |
| 239 | test('cantListBut', reqList('/cantListBut/', 403)) |
| 240 | test('cantListBut.zip', req('/cantListBut/?get=zip', 403)) |
| 241 | test('cantListBut.parent', reqList('/', { permInList: { 'cantListBut/': 'l' } })) |
| 242 | test('cantListBut.child masked', reqList('/cantListBut/page', 200)) |
| 243 | test('cantSearchForMasks', reqList('/', { outList: ['cantSearchForMasks/page/gpl.png'] }, { search: 'gpl' })) |
| 244 | test('onlyFiles.deep', reqList('/onlyFilesDeep', { outList: ['top/mid/'] }, { onlyFiles: true, search: 'mid' })) |
| 245 | test('cantSearchForMasks.deep', reqList('/cantSearchForMasksDeep', { inList: ['gpl-visible.png'], outList: ['page/gpl.png'] }, { search: 'gpl' })) |
| 246 | test('masks.overlap.basename+path', reqList('/maskOverlap', { |
| 247 | inList: ['gpl-visible.png'], |
| 248 | outList: ['page/gpl.png'], |
| 249 | cb: data => /[rR]/.test(_.find(data?.list, { n: 'gpl-visible.png' })?.p || ''), |
| 250 | }, { search: 'gpl' })) |
| 251 | test('mask.onRenamedPath', async () => { |
| 252 | await reqList('/maskOnRenamedPath', { outList: ['page/renamed-gpl.png'] }, { search: 'renamed-gpl' })() |
| 253 | await reqList('/maskOnRenamedPath', { outList: ['nested/page/renamed-gpl-nested.png'] }, { search: 'renamed-gpl-nested' })() |
| 254 | }) |
| 255 | test('cantReadBut', reqList('/cantReadBut/', 403)) |
| 256 | test('cantReadBut.can', req('/cantReadBut/alfa.txt', 200)) |
| 257 | test('cantReadBut.parent', reqList('/', { permInList: { 'cantReadBut/': '!r' } })) |
| 258 | test('cantReadButChild', req('/cantReadButChild/alfa.txt', 401)) |
| 259 | test('cantReadButChild.parent', reqList('/', { permInList: { 'cantReadButChild/': 'R' } })) |
| 260 | |
| 261 | test('cantReadPage', reqList('/cantReadPage/page', 403)) |
| 262 | test('cantReadPage.zip', req('/cantReadPage/page/?get=zip', 403, { method:'HEAD' })) |
| 263 | test('cantReadPage.file', req('/cantReadPage/page/gpl.png', 403)) |
| 264 | test('cantReadPage.parent', reqList('/cantReadPage', { permInList: { 'page/': 'lr' } })) |
| 265 | test('cantReadRealFolder', reqList('/cantReadRealFolder', 403)) |
| 266 | test('cantReadRealFolder.file', req('/cantReadRealFolder/page/gpl.png', 403)) |
| 267 | |
| 268 | test('renameChild', reqList('/renameChild/tests', { inList:['renamed1'] })) |
| 269 | test('renameChild.get', req('/renameChild/tests/renamed1', /abc/)) |
| 270 | test('renameChild.deeper', reqList('/renameChild/tests/page', { inList:['renamed2'] })) |
| 271 | test('renameChild.get deeper', req('/renameChild/tests/page/renamed2', /PNG/)) |
| 272 | test('renameChild.search', reqList('/renameChild/tests', { inList:['renamed1', 'page/renamed2'] }, { search: 'ren' })) |
| 273 | |
| 274 | test('cantSeeThis', reqList('/', { outList:['cantSeeThis/'] })) |
| 275 | test('cantSeeThis.children', reqList('/cantSeeThis', { outList:['hi/'] })) |
| 276 | test('cantSeeThisButChildren', reqList('/', { outList:['cantSeeThisButChildren/'] })) |
| 277 | test('cantSeeThisButChildren.children', reqList('/cantSeeThisButChildren', { inList:['hi/'] })) |
| 278 | test('cantZipFolder', req('/cantSeeThisButChildren/?get=zip', 403)) |
| 279 | test('cantZipFolder.butChildren', req('/cantSeeThisButChildren/hi/?get=zip', 200)) |
| 280 | test('cantSeeThisButChildrenMasks', reqList('/', { outList:['cantSeeThisButChildrenMasks/'] })) |
| 281 | test('cantSeeThisButChildrenMasks.children', reqList('/cantSeeThisButChildrenMasks', { inList:['hi/'] })) |
| 282 | |
| 283 | test('masks.only', reqList('/cantSeeThisButChildren/hi', { inList:['page/'] })) |
| 284 | test('masks.only.fromDisk', reqList('/cantSeeThisButChildren/hi/page', 403)) |
| 285 | test('masks.only.fromDisk.file', req('/cantSeeThisButChildren/hi/page/gpl.png', 403)) |
| 286 | |
| 287 | test('protectFromAbove', req('/protectFromAbove/child/alfa.txt', 403)) |
| 288 | test('protectFromAbove.list', reqList('/protectFromAbove/child/', { inList:['alfa.txt'] })) |
| 289 | test('inheritNegativeMask', reqList('/tests/page', { outList: ['index.html'] })) |
| 290 | |
| 291 | const zipSize = 13242 |
| 292 | const zipOfs = 0x194E |
| 293 | const zipLength = 4 |
| 294 | test('zip.head', req('/f1/?get=zip', { empty:true, length:zipSize }, { method:'HEAD' }) ) |
| 295 | test('zip.partial', req('/f1/?get=zip', { re:/^page$/, length: zipLength }, { headers: { Range: `bytes=${zipOfs}-${zipOfs+zipLength-1}` } }) ) |
| 296 | test('zip.partial.resume', req('/f1/?get=zip', { re:/^page/, length:zipSize-zipOfs }, { headers: { Range: `bytes=${zipOfs}-` } }) ) |
| 297 | test('zip.partial.end', req('/f1/f2/?get=zip', { re:/^6/, length:10 }, { headers: { Range: 'bytes=-10' } }) ) |
| 298 | test('zip.list.compacted folders', req('/f1/?get=zip&list=page%2Fgpl.png%2F%2F%00index.html', /page\/gpl.png.+page\/index.html/)) |
| 299 | test('zip.list.selected folder decodes prefix', req('/tests/?get=zip&list=C%253A', data => |
| 300 | String(data).includes('C:/gpl.png') && !String(data).includes('C%3A/gpl.png'))) |
| 301 | test('zip.list.selected nested folder preserves path', async () => { |
| 302 | const url = '/?get=zip&list=f1%2Fpage' |
| 303 | const { body } = await httpWithBody(BASE_URL + url, { path: url }) |
| 304 | const paths = (await unzipper.Open.buffer(body!)).files.map(x => x.path) |
| 305 | if (!paths.includes('f1/page/') || paths.includes('page/')) |
| 306 | throw Error('unexpected archive paths: ' + paths) |
| 307 | }) |
| 308 | test('zip.list.bad encoding', req('/f1/?get=zip&list=%E0%A4%A//%00', { status: 200, length: 22 })) // basically empty |
| 309 | test('zip.list.null filename', req('/f1/?get=zip&list=%00', 400)) // tries to name the output with null-byte |
| 310 | test('zip.masked deep', req('/cantSearchForMasksDeep/?get=zip', { |
| 311 | status: 200, |
| 312 | cb: data => !data.includes('page/gpl.png') && data.includes('gpl-visible.png'), |
| 313 | })) |
| 314 | test('zip.alfa is forbidden', req('/protectFromAbove/child/?get=zip&list=alfa.txt//renamed', { empty: true, length:134 }, { method:'HEAD' })) |
| 315 | test('zip.cantReadPage', req('/cantReadPage/?get=zip', { length: 4832 }, { method:'HEAD' })) |
| 316 | |
| 317 | test('referer', req('/f1/page/gpl.png', 403, { |
| 318 | headers: { Referer: 'https://some-website.com/try-to-trick/x.com/' } |
| 319 | })) |
| 320 | |
| 321 | test('upload.need account', reqUpload( UPLOAD_DEST, 401)) |
| 322 | test('upload.post', async () => { // this is also testing basic-auth |
| 323 | const output = await execP(`curl -u ${auth} -F upload=@${SAMPLE_FILE_PATH} ${BASE_URL}${UPLOAD_ROOT}`) |
| 324 | const uri = tryJson(output)?.uris?.[0] |
| 325 | if (!uri) throw "unexpected output " + output |
| 326 | const fn = uploadUriToPath(uri) |
| 327 | const stats = statSync(fn) |
| 328 | rm(fn).catch(() => {}) // clear |
| 329 | if (stats?.size !== statSync(SAMPLE_FILE_PATH).size) |
| 330 | throw "unexpected size for " + fn |
| 331 | }) |
| 332 | test('upload.post.virtual folder', async () => { |
| 333 | const { status } = await curlWithStatus(`curl -s -u ${auth} -F upload=@${SAMPLE_FILE_PATH} ${BASE_URL}${VIRTUAL_UPLOAD_ROOT}`) |
| 334 | if (status !== 403) |
| 335 | throw "unexpected status " + status |
| 336 | }) |
| 337 | test('upload.put.virtual folder', reqUpload(`${VIRTUAL_UPLOAD_ROOT}gpl.png`, 403)) |
| 338 | test('upload.post.empty filename', async () => { |
| 339 | const boundary = '----hfs-boundary' |
| 340 | const body = `--${boundary}\\r\\nContent-Disposition: form-data; name="upload"; filename=""\\r\\nContent-Type: application/octet-stream\\r\\n\\r\\nX\\r\\n--${boundary}--\\r\\n` |
| 341 | const { status, body: responseBody } = await curlWithStatus(`printf '%b' "${body}" | curl -s -u ${auth} -H "Content-Type: multipart/form-data; boundary=${boundary}" --data-binary @- ${BASE_URL}${UPLOAD_ROOT}`) |
| 342 | if (status !== 400) |
| 343 | throw "unexpected status " + status |
| 344 | const errMsg = tryJson(responseBody)?.errors?.[0] |
| 345 | if (!['empty filename', 'no files'].includes(errMsg)) |
| 346 | throw 'missing error' |
| 347 | }) |
| 348 | test('upload.post.missing-boundary', async () => { |
| 349 | const { status } = await curlWithStatus(`printf 'x' | curl -s -u ${auth} -H "Content-Type: multipart/form-data" --data-binary @- ${BASE_URL}${UPLOAD_ROOT}`) |
| 350 | if (status !== 400) |
| 351 | throw "unexpected status " + status |
| 352 | }) |
| 353 | test('upload.post.absolute filename', async () => { |
| 354 | const absPath = resolve(__dirname, `abs-${randomId(6)}.txt`) |
| 355 | const absForBody = absPath.replace(/\\\\/g, '/') |
| 356 | const storedPath = resolve(__dirname, 'tmp', basename(absPath)) |
| 357 | try { |
| 358 | const { status } = await curlWithStatus(`curl -s -u ${auth} -H "x-hfs-wait: 1" -F "upload=@${SAMPLE_FILE_PATH};filename=${absForBody}" ${BASE_URL}${UPLOAD_ROOT}`) |
| 359 | throwIf(status !== 418 ? "unexpected status " + status |
| 360 | : existsSync(absPath) ? "absolute path accepted" |
| 361 | : existsSync(storedPath) ? "stored file escaped" : '') |
| 362 | } |
| 363 | finally { |
| 364 | await Promise.all([rmAny(absPath), rmAny(storedPath)]) |
| 365 | } |
| 366 | }) |
| 367 | test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: UPLOAD_DIR }, 401)) |
| 368 | test('create_folder.bad type', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 123 }, { status: 400, re: /name/ })) |
| 369 | test('delete.no perm', req('/for-admins/', 405, { method: 'delete' })) |
| 370 | test('delete.need account', req(UPLOAD_ROOT + 'alfa.txt', 401, { method: 'delete'})) |
| 371 | test('rename.no perm', reqApi('rename', { uri: '/for-admins', dest: 'any' }, 403)) |
| 372 | |
| 373 | test('create_folder.bad encoding', reqApi('comment', { uri: '%a' }, 400)) |
| 374 | test('comment.bad encoding', reqApi('comment', { uri: '%a', comment: 'anything' }, 400)) |
| 375 | test('rename.bad encoding', reqApi('rename', { uri: '%a', dest: 'anything' }, 400)) |
| 376 | test('move_files.bad encoding', reqApi('move_files', { uri_from: ['%a'], uri_to: '%a' }, 400)) |
| 377 | |
| 378 | test('folder size', reqApi('get_folder_size', { uri: 'f1/page' }, res => res.bytes === 6328 )) |
| 379 | test('folder size.cant', reqApi('get_folder_size', { uri: 'for-admins' }, 401)) |
| 380 | |
| 381 | test('get_accounts', reqApi('get_accounts', {}, 401)) // admin api requires login |
| 382 | test('url login', async () => { |
| 383 | const output = await execP(`curl -s -D - -o /dev/null "${BASE_URL}/for-admins/?login=${auth}"`) |
| 384 | if (!/^(location|set-cookie):/im.test(output)) |
| 385 | throw "failed" |
| 386 | }) |
| 387 | }) |
| 388 | |
| 389 | describe('webdav', () => { |
| 390 | const jar = {} |
| 391 | after(() => rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR))) |
| 392 | test('webdav force login.scope propfind', req('/f1/', 401, { method: 'PROPFIND', headers: { depth: '0' }, jar })) |
| 393 | test('webdav force login.scope options', req('/f1/', (_data, res) => |
| 394 | res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, { |
| 395 | method: 'OPTIONS', |
| 396 | headers: { 'user-agent': OFFICE_WEBDAV_UA }, |
| 397 | jar: {}, |
| 398 | })) |
| 399 | test('webdav force login.scope get', req('/f1/protected', (_data, res) => |
| 400 | res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, { |
| 401 | headers: { 'user-agent': OFFICE_WEBDAV_UA }, |
| 402 | jar: {}, |
| 403 | })) |
| 404 | test('webdav.get keeps webdav challenge after denied read', async () => { |
| 405 | const user = `wd-read-${randomId(6)}`.toLowerCase() |
| 406 | const pass = `pw-${randomId(8)}` |
| 407 | const adminReq = { auth, jar: {} } |
| 408 | try { |
| 409 | await reqApi('add_account', { username: user, overwrite: true, password: pass }, res => res?.username === user, adminReq)() |
| 410 | await req('/f1/protected', (_data, res) => |
| 411 | res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, { |
| 412 | auth: `${user}:${pass}`, |
| 413 | headers: { 'user-agent': OFFICE_WEBDAV_UA }, |
| 414 | jar: {}, |
| 415 | })() |
| 416 | } |
| 417 | finally { |
| 418 | await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {}) |
| 419 | } |
| 420 | }) |
| 421 | test('webdav options works after auth', req('/f1/', (_data, res) => |
| 422 | res.statusCode === 200 && res.headers?.dav === '1,2', { |
| 423 | method: 'OPTIONS', |
| 424 | auth, |
| 425 | headers: { 'user-agent': OFFICE_WEBDAV_UA }, |
| 426 | jar: {}, |
| 427 | })) |
| 428 | test('webdav.put detects client after propfind', async () => { |
| 429 | const name = `wd-detected-${randomId(6)}.txt` |
| 430 | const ua = `hfs-test-detected-${randomId(6)}` |
| 431 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 432 | let destPath = '' |
| 433 | try { |
| 434 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'dest', ua)() |
| 435 | await req(uri, 207, { method: 'PROPFIND', auth, jar, headers: { depth: '0', 'user-agent': ua } })() |
| 436 | const secondPath = await webdavUpload(uri, x => x?.uri === uri, 'source', ua)() |
| 437 | if (secondPath !== destPath) |
| 438 | throw "destination changed unexpectedly" |
| 439 | if (readFileSync(destPath, 'utf8') !== 'source') |
| 440 | throw "destination wasn't overwritten" |
| 441 | } |
| 442 | finally { |
| 443 | await rmAny(destPath) |
| 444 | } |
| 445 | }) |
| 446 | test('webdav.put default-overwrite with can_delete', async () => { |
| 447 | const name = `wd-overwrite-${randomId(6)}.txt` |
| 448 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 449 | let destPath = '' |
| 450 | try { |
| 451 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'dest')() |
| 452 | const secondPath = await webdavUpload(uri, x => x?.uri === uri, 'source')() |
| 453 | if (secondPath !== destPath) |
| 454 | throw "destination changed unexpectedly" |
| 455 | if (readFileSync(destPath, 'utf8') !== 'source') |
| 456 | throw "destination wasn't overwritten" |
| 457 | } |
| 458 | finally { |
| 459 | await rmAny(destPath) |
| 460 | } |
| 461 | }) |
| 462 | test('webdav.put overwrite forbidden without can_delete', async () => { |
| 463 | const name = `wd-nodelete-${randomId(6)}.txt` |
| 464 | const uri = `${CANT_OVERWRITE_URI}${name}` |
| 465 | const dir = await ensureCantOverwriteDir() |
| 466 | const destPath = resolve(dir, name) |
| 467 | await writeFile(destPath, 'dest') |
| 468 | try { |
| 469 | await webdavUpload(uri, 403, 'source')() |
| 470 | if (readFileSync(destPath, 'utf8') !== 'dest') |
| 471 | throw "destination changed" |
| 472 | } |
| 473 | finally { |
| 474 | await rmAny(destPath) |
| 475 | } |
| 476 | }) |
| 477 | test('webdav.put failed overwrite does not grant grace', async () => { |
| 478 | const name = `wd-failed-grace-${randomId(6)}.txt` |
| 479 | const uri = `${CANT_OVERWRITE_URI}${name}` |
| 480 | const dir = await ensureCantOverwriteDir() |
| 481 | const destPath = resolve(dir, name) |
| 482 | await writeFile(destPath, 'dest') |
| 483 | try { |
| 484 | await req(uri, 403, { |
| 485 | method: 'PUT', |
| 486 | auth, |
| 487 | jar, |
| 488 | headers: { 'content-length': '0', 'user-agent': WEBDAV_UA }, |
| 489 | body: '', |
| 490 | })() |
| 491 | await webdavUpload(uri, 403, 'source')() |
| 492 | if (readFileSync(destPath, 'utf8') !== 'dest') |
| 493 | throw "destination changed" |
| 494 | } |
| 495 | finally { |
| 496 | await rmAny(destPath) |
| 497 | } |
| 498 | }) |
| 499 | test('webdav.put grants grace after successful encoded empty upload', async () => { |
| 500 | const name = `wd-grace-${randomId(6)} %#.txt` |
| 501 | const uri = `${CANT_OVERWRITE_URI}${pathEncode(name)}` |
| 502 | const dir = await ensureCantOverwriteDir() |
| 503 | const destPath = resolve(dir, name) |
| 504 | try { |
| 505 | await req(uri, (x, res) => { |
| 506 | if (res.statusCode !== 200) |
| 507 | throw `expected first PUT 200, got ${res.statusCode}` |
| 508 | if (x?.uri !== uri) |
| 509 | throw "first PUT uri mismatch" |
| 510 | }, { |
| 511 | method: 'PUT', |
| 512 | auth, |
| 513 | jar, |
| 514 | headers: { 'content-length': '0', 'user-agent': WEBDAV_UA }, |
| 515 | body: '', |
| 516 | })() |
| 517 | await req(uri, (x, res) => { |
| 518 | if (res.statusCode !== 200) |
| 519 | throw `expected second PUT 200, got ${res.statusCode}` |
| 520 | if (x?.uri !== uri) |
| 521 | throw "second PUT uri mismatch" |
| 522 | }, { |
| 523 | method: 'PUT', |
| 524 | auth, |
| 525 | jar, |
| 526 | headers: { 'x-expected-entity-length': String(Buffer.byteLength('source')), 'user-agent': WEBDAV_UA }, |
| 527 | body: 'source', |
| 528 | })() |
| 529 | if (readFileSync(destPath, 'utf8') !== 'source') |
| 530 | throw "destination not overwritten" |
| 531 | } |
| 532 | finally { |
| 533 | await rmAny(destPath) |
| 534 | } |
| 535 | }) |
| 536 | test('webdav.put grace is bound to username', async () => { |
| 537 | const firstUser = `wd-grace-a-${randomId(6)}`.toLowerCase() |
| 538 | const secondUser = `wd-grace-b-${randomId(6)}`.toLowerCase() |
| 539 | const firstPass = `pw-${randomId(8)}` |
| 540 | const secondPass = `pw-${randomId(8)}` |
| 541 | const name = `wd-grace-${randomId(6)}.txt` |
| 542 | const uri = `${CANT_OVERWRITE_URI}${name}` |
| 543 | const dir = await ensureCantOverwriteDir() |
| 544 | const destPath = resolve(dir, name) |
| 545 | const adminReq = { auth, jar: {} } |
| 546 | try { |
| 547 | await reqApi('add_account', { username: firstUser, overwrite: true, password: firstPass, belongs: ['admins'] }, res => res?.username === firstUser, adminReq)() |
| 548 | await reqApi('add_account', { username: secondUser, overwrite: true, password: secondPass, belongs: ['admins'] }, res => res?.username === secondUser, adminReq)() |
| 549 | await rmAny(destPath) |
| 550 | await req(uri, x => x?.uri === uri, { |
| 551 | method: 'PUT', |
| 552 | auth: `${firstUser}:${firstPass}`, |
| 553 | jar: {}, |
| 554 | headers: { 'content-length': '0', 'user-agent': WEBDAV_UA, }, |
| 555 | body: '', |
| 556 | })() |
| 557 | if (!existsSync(destPath)) |
| 558 | throw "first upload did not create the file" |
| 559 | await req(uri, 403, { |
| 560 | method: 'PUT', |
| 561 | auth: `${secondUser}:${secondPass}`, |
| 562 | jar: {}, |
| 563 | headers: { 'content-length': String(Buffer.byteLength('source')), 'user-agent': WEBDAV_UA }, |
| 564 | body: 'source', |
| 565 | })() |
| 566 | if (readFileSync(destPath, 'utf8') !== '') |
| 567 | throw "second upload unexpectedly overwrote destination" |
| 568 | } |
| 569 | finally { |
| 570 | await reqApi('del_account', { username: [firstUser, secondUser] }, 200, adminReq)().catch(() => {}) |
| 571 | await rmAny(destPath) |
| 572 | } |
| 573 | }) |
| 574 | test('webdav.lock requires write permission', async () => { |
| 575 | const user = `wd-lock-readonly-${randomId(6)}`.toLowerCase() |
| 576 | const password = randomId(10) |
| 577 | const uri = '/tests/page/gpl.png' |
| 578 | let token = '' |
| 579 | const adminReq = { auth, jar: {} } |
| 580 | try { |
| 581 | await reqApi('add_account', { username: user, password }, res => res?.username === user, adminReq)() |
| 582 | await req(uri, 401, { |
| 583 | method: 'LOCK', |
| 584 | auth: `${user}:${password}`, |
| 585 | jar: {}, |
| 586 | headers: { 'content-type': 'text/xml', 'user-agent': WEBDAV_UA }, |
| 587 | body: WEBDAV_LOCK_BODY, |
| 588 | })() |
| 589 | await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')() |
| 590 | } |
| 591 | finally { |
| 592 | if (token) |
| 593 | await webdavUnlock(uri, token)().catch(() => {}) |
| 594 | await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {}) |
| 595 | } |
| 596 | }) |
| 597 | test('webdav.lock allows a missing upload destination', async () => { |
| 598 | const uri = `${UPLOAD_ROOT}wd-lock-missing-${randomId(6)}.txt` |
| 599 | let token = '' |
| 600 | try { |
| 601 | await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')() |
| 602 | if (!token) |
| 603 | throw "missing lock token" |
| 604 | } |
| 605 | finally { |
| 606 | if (token) |
| 607 | await webdavUnlock(uri, token)().catch(() => {}) |
| 608 | } |
| 609 | }) |
| 610 | test('webdav.lock refresh keeps token', async () => { |
| 611 | const name = `wd-lock-${randomId(6)}.txt` |
| 612 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 613 | let destPath = '' |
| 614 | let token = '' |
| 615 | try { |
| 616 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 617 | await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')() |
| 618 | if (!token) |
| 619 | throw "missing lock token" |
| 620 | await webdavLock(uri, (_data, res) => |
| 621 | res.statusCode === 200 && res.headers?.[TOKEN_HEADER] === token, '', { If: `(<${token}>)` })() |
| 622 | } |
| 623 | finally { |
| 624 | if (token) |
| 625 | await webdavUnlock(uri, token)().catch(() => {}) |
| 626 | await rmAny(destPath) |
| 627 | } |
| 628 | }) |
| 629 | test('webdav.lock rejects shared lock', async () => { |
| 630 | const name = `wd-lock-shared-${randomId(6)}.txt` |
| 631 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 632 | let destPath = '' |
| 633 | try { |
| 634 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 635 | await webdavLock(uri, 409, WEBDAV_SHARED_LOCK_BODY)() |
| 636 | } |
| 637 | finally { |
| 638 | await rmAny(destPath) |
| 639 | } |
| 640 | }) |
| 641 | test('webdav.stale lock on missing resource is pruned', async () => { |
| 642 | const name = `wd-stale-lock-${randomId(6)}.txt` |
| 643 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 644 | let destPath = '' |
| 645 | try { |
| 646 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 647 | await webdavLock(uri)() |
| 648 | // simulate external removal while client forgot to unlock: stale lock must not force 423 forever |
| 649 | await rmAny(destPath) |
| 650 | await req(uri, 404, { method: 'DELETE', auth, jar, headers: { 'user-agent': WEBDAV_UA } })() |
| 651 | await req(uri, 404, { method: 'DELETE', auth, jar, headers: { 'user-agent': WEBDAV_UA } })() |
| 652 | } |
| 653 | finally { |
| 654 | await rmAny(destPath) |
| 655 | } |
| 656 | }) |
| 657 | test('webdav.delete success clears lock for same path', async () => { |
| 658 | const name = `wd-delete-clears-lock-${randomId(6)}.txt` |
| 659 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 660 | let destPath = '' |
| 661 | let token = '' |
| 662 | try { |
| 663 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 664 | await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')() |
| 665 | if (!token) |
| 666 | throw "missing lock token" |
| 667 | await req(uri, 200, { method: 'DELETE', auth, jar, headers: { If: `(<${token}>)`, 'user-agent': WEBDAV_UA } })() |
| 668 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test2')() |
| 669 | await req(uri, 200, { method: 'DELETE', auth, jar, headers: { 'user-agent': WEBDAV_UA } })() |
| 670 | } |
| 671 | finally { |
| 672 | await rmAny(destPath) |
| 673 | } |
| 674 | }) |
| 675 | test('webdav.move success clears lock state', async () => { |
| 676 | const name = `wd-move-clears-lock-${randomId(6)}.txt` |
| 677 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 678 | const renamedName = name.replace('.txt', '-renamed.txt') |
| 679 | const renamed = `${UPLOAD_ROOT}${UPLOAD_DIR}/${renamedName}` |
| 680 | let destPath = '' |
| 681 | let renamedPath = '' |
| 682 | let token = '' |
| 683 | try { |
| 684 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 685 | await webdavLock(uri, (_data, res) => token = res.headers?.[TOKEN_HEADER] || '')() |
| 686 | if (!token) |
| 687 | throw "missing lock token" |
| 688 | await req(uri, 201, { |
| 689 | method: 'MOVE', |
| 690 | auth, |
| 691 | jar, |
| 692 | headers: { |
| 693 | destination: BASE_URL + renamed, |
| 694 | overwrite: 'F', |
| 695 | If: `(<${token}>)`, |
| 696 | 'user-agent': WEBDAV_UA, |
| 697 | }, |
| 698 | })() |
| 699 | renamedPath = uploadUriToPath(renamed) |
| 700 | await req(renamed, 200, { method: 'DELETE', auth, jar, headers: { 'user-agent': WEBDAV_UA } })() |
| 701 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test2')() |
| 702 | await req(uri, 200, { method: 'DELETE', auth, jar, headers: { 'user-agent': WEBDAV_UA } })() |
| 703 | } |
| 704 | finally { |
| 705 | await rmAny(renamedPath) |
| 706 | await rmAny(destPath) |
| 707 | } |
| 708 | }) |
| 709 | test('webdav.move rename decodes escaped segment chars', async () => { |
| 710 | for (const marker of [',', '#', '%']) { |
| 711 | const name = `wd-move-${randomId(6)}.txt` |
| 712 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 713 | const renamedName = name.replace('.txt', `${marker}renamed.txt`) |
| 714 | const renamed = `${UPLOAD_ROOT}${UPLOAD_DIR}/${pathEncode(renamedName)}` |
| 715 | const destination = `${UPLOAD_ROOT}${UPLOAD_DIR}/${encodeURIComponent(renamedName)}` |
| 716 | let destPath = '' |
| 717 | try { |
| 718 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 719 | await req(uri, 201, { |
| 720 | method: 'MOVE', |
| 721 | auth, |
| 722 | jar, |
| 723 | headers: { |
| 724 | destination: BASE_URL + destination, |
| 725 | overwrite: 'F', |
| 726 | 'user-agent': WEBDAV_UA, |
| 727 | }, |
| 728 | })() |
| 729 | await req(uri, 404)() |
| 730 | await req(renamed, 200, { auth })() |
| 731 | } |
| 732 | finally { |
| 733 | await rmAny(uploadUriToPath(uri)) |
| 734 | await rmAny(uploadUriToPath(renamed)) |
| 735 | await rmAny(destPath) |
| 736 | } |
| 737 | } |
| 738 | }) |
| 739 | test('webdav.move rename cannot traverse out of root', async () => { |
| 740 | const name = `wd-move-trav-${randomId(6)}.txt` |
| 741 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 742 | const escapedName = `wd-escaped-${randomId(6)}.txt` |
| 743 | const traversal = `../../${escapedName}` // climbs above the upload node's source |
| 744 | // encode as a single path segment so dirname(dest) still matches dirname(path) and we hit the rename branch |
| 745 | const destination = `${BASE_URL}${UPLOAD_ROOT}${UPLOAD_DIR}/${encodeURIComponent(traversal)}` |
| 746 | const escapedDiskPath = resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR, traversal) |
| 747 | let destPath = '' |
| 748 | try { |
| 749 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 750 | await req(uri, 400, { method: 'MOVE', auth, jar, headers: { destination, overwrite: 'F', 'user-agent': WEBDAV_UA } })() |
| 751 | if (await access(escapedDiskPath).then(() => true, () => false)) |
| 752 | throw "file escaped the VFS root" |
| 753 | await req(uri, 200, { auth })() // source must still be there, untouched |
| 754 | } |
| 755 | finally { |
| 756 | await rmAny(escapedDiskPath) |
| 757 | await rmAny(destPath) |
| 758 | } |
| 759 | }) |
| 760 | test('webdav.proppatch accepts dead properties as no-op', async () => { |
| 761 | const name = `wd-proppatch-${randomId(6)}.txt` |
| 762 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${name}` |
| 763 | let destPath = '' |
| 764 | try { |
| 765 | destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')() |
| 766 | await req(uri, data => data.includes(`<href>${uri}</href>`) && !data.includes(`<href>${uri}/</href>`), { |
| 767 | method: 'PROPFIND', |
| 768 | auth, |
| 769 | jar, |
| 770 | headers: { depth: '0', 'user-agent': WEBDAV_UA }, |
| 771 | })() |
| 772 | await req(uri, (data, res) => { |
| 773 | if (res.statusCode !== 207) |
| 774 | throw `expected 207, got ${res.statusCode}` |
| 775 | if (XMLValidator.validate(data) !== true) |
| 776 | throw "invalid XML" |
| 777 | if (!/<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 200 OK/.test(data)) |
| 778 | throw "missing no-op success for Windows property" |
| 779 | if (!/<getlastmodified\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data)) |
| 780 | throw "missing forbidden status for protected live property" |
| 781 | }, { |
| 782 | method: 'PROPPATCH', |
| 783 | auth, |
| 784 | jar, |
| 785 | headers: { |
| 786 | 'content-type': 'text/xml', |
| 787 | 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY), |
| 788 | 'user-agent': WEBDAV_UA, |
| 789 | }, |
| 790 | body: WEBDAV_PROPPATCH_BODY, |
| 791 | })() |
| 792 | if (Math.abs(statSync(destPath).mtimeMs - Date.parse('Mon, 04 May 2026 10:00:00 GMT')) > 1000) |
| 793 | throw "mtime was not updated" |
| 794 | } |
| 795 | finally { |
| 796 | await rmAny(destPath) |
| 797 | } |
| 798 | }) |
| 799 | test('webdav.proppatch requires upload permission for timestamp changes', req('/f1/f2/alfa.txt', data => |
| 800 | /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data), { |
| 801 | method: 'PROPPATCH', |
| 802 | auth, |
| 803 | jar, |
| 804 | headers: { |
| 805 | 'content-type': 'text/xml', |
| 806 | 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY), |
| 807 | 'user-agent': WEBDAV_UA, |
| 808 | }, |
| 809 | body: WEBDAV_PROPPATCH_BODY, |
| 810 | })) |
| 811 | test('webdav.proppatch metadata grace is bound to recent upload', async () => { |
| 812 | const staleName = `wd-proppatch-stale-${randomId(6)}.txt` |
| 813 | const freshName = `wd-proppatch-fresh-${randomId(6)}.txt` |
| 814 | const staleUri = `${CANT_OVERWRITE_URI}${staleName}` |
| 815 | const freshUri = `${CANT_OVERWRITE_URI}${freshName}` |
| 816 | const dir = await ensureCantOverwriteDir() |
| 817 | const stalePath = resolve(dir, staleName) |
| 818 | let freshPath = '' |
| 819 | try { |
| 820 | await writeFile(stalePath, 'stale') |
| 821 | await req(staleUri, data => /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 403 Forbidden/.test(data), { |
| 822 | method: 'PROPPATCH', |
| 823 | auth, |
| 824 | jar, |
| 825 | headers: { |
| 826 | 'content-type': 'text/xml', |
| 827 | 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY), |
| 828 | 'user-agent': WEBDAV_UA, |
| 829 | }, |
| 830 | body: WEBDAV_PROPPATCH_BODY, |
| 831 | })() |
| 832 | freshPath = await webdavUpload(freshUri, x => x?.uri === freshUri, 'fresh')() |
| 833 | await req(freshUri, data => /<Win32LastModifiedTime\/>[\s\S]*HTTP\/1\.1 200 OK/.test(data), { |
| 834 | method: 'PROPPATCH', |
| 835 | auth, |
| 836 | jar, |
| 837 | headers: { |
| 838 | 'content-type': 'text/xml', |
| 839 | 'content-length': Buffer.byteLength(WEBDAV_PROPPATCH_BODY), |
| 840 | 'user-agent': WEBDAV_UA, |
| 841 | }, |
| 842 | body: WEBDAV_PROPPATCH_BODY, |
| 843 | })() |
| 844 | } |
| 845 | finally { |
| 846 | await rmAny(stalePath) |
| 847 | await rmAny(freshPath) |
| 848 | } |
| 849 | }) |
| 850 | test('webdav.escaping', req('/f1/hidden', data => XMLValidator.validate(data) === true, { method: 'PROPFIND', auth, jar: {}, headers: { depth: '1' } })) |
| 851 | |
| 852 | function webdavUpload(uri: string, tester: Tester, body: string, userAgent=WEBDAV_UA) { |
| 853 | return () => req(uri, tester, { |
| 854 | method: 'PUT', |
| 855 | auth, |
| 856 | jar, |
| 857 | headers: { |
| 858 | 'content-length': Buffer.byteLength(body), |
| 859 | 'user-agent': userAgent, |
| 860 | }, |
| 861 | body, |
| 862 | })().then(res => uploadUriToPath(res?.uri || uri)) |
| 863 | } |
| 864 | |
| 865 | function webdavLock(uri: string, tester: Tester=200, body=WEBDAV_LOCK_BODY, headers?: Record<string, string>) { |
| 866 | return req(uri, tester, { |
| 867 | method: 'LOCK', |
| 868 | auth, |
| 869 | jar, |
| 870 | headers: { |
| 871 | 'content-type': 'text/xml', |
| 872 | 'content-length': Buffer.byteLength(body), |
| 873 | 'user-agent': WEBDAV_UA, |
| 874 | ...headers, |
| 875 | }, |
| 876 | body, |
| 877 | }) |
| 878 | } |
| 879 | |
| 880 | function webdavUnlock(uri: string, token: string, tester: Tester=204) { |
| 881 | return req(uri, tester, { |
| 882 | method: 'UNLOCK', |
| 883 | auth, |
| 884 | jar, |
| 885 | headers: { |
| 886 | 'user-agent': WEBDAV_UA, |
| 887 | [TOKEN_HEADER]: `<${token}>`, |
| 888 | }, |
| 889 | }) |
| 890 | } |
| 891 | |
| 892 | }) |
| 893 | |
| 894 | // do this before login, or max_dl.accounts config will override max_dl |
| 895 | describe('limits', () => { |
| 896 | const fn = ROOT + 'big' |
| 897 | before(() => writeFile(fn, BIG_CONTENT)) |
| 898 | test('max_dl', () => testMaxDl('/' + fn, 1, 2, { jar: {} })) |
| 899 | after(() => rm(fn)) |
| 900 | }) |
| 901 | |
| 902 | describe('sessions', () => { |
| 903 | test('of_disabled.cantLogin', () => login('of_disabled').then(() => { throw "in" }, () => {})) |
| 904 | test('allow_net.canLogin', () => login(username)) |
| 905 | test('allow_net.cantLogin', () => { |
| 906 | defaultBaseUrl = BASE_URL_127 // 127.0.0.1 is not allowed for this account |
| 907 | return login(username).then(() => { throw "in" }, () => {}) |
| 908 | .finally(() => defaultBaseUrl = BASE_URL) |
| 909 | }) |
| 910 | test('allow_net.cantLogin.url', reqList('protected', 401, {}, { baseUrl: BASE_URL_127, auth })) |
| 911 | test('httpStream.jar isolates host cookies', async () => { |
| 912 | const jar = {} |
| 913 | await reqApi('loginSrp1', { username }, res => Boolean(res?.salt && res?.pubKey), { jar })() |
| 914 | await reqApi('loginSrp2', { pubKey: '1', proof: '1' }, 409, { baseUrl: BASE_URL_127, jar })() |
| 915 | await reqApi('loginSrp2', { pubKey: '1', proof: '1' }, 401, { jar })() |
| 916 | }) |
| 917 | test('allow_net.recovers after restriction is removed', async () => { |
| 918 | const user = `allow-net-${randomId(6)}`.toLowerCase() |
| 919 | const pwd = `pw-${randomId(8)}` |
| 920 | const userAuth = `${user}:${pwd}` |
| 921 | const userJar = {} |
| 922 | const adminReq = { auth, jar: {} } |
| 923 | try { |
| 924 | await reqApi('add_account', { username: user, overwrite: true, password: pwd }, res => res?.username === user, adminReq)() |
| 925 | await reqApi('refresh_session', {}, res => res?.username === user, { jar: userJar, auth: userAuth })() |
| 926 | await reqApi('set_account', { username: user, changes: { allow_net: '127.0.0.1' } }, 200, adminReq)() // block |
| 927 | await reqApi('refresh_session', {}, res => !res?.username, { jar: userJar })() // kicked out |
| 928 | await reqApi('set_account', { username: user, changes: { allow_net: '' } }, 200, adminReq)() // re-enable |
| 929 | await reqApi('refresh_session', {}, res => res?.username === user, { jar: userJar, auth: userAuth })() |
| 930 | } |
| 931 | finally { |
| 932 | await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {}) |
| 933 | } |
| 934 | }) |
| 935 | test('allow_net cache follows account switch', async () => { |
| 936 | const u = `allow-net-switch-${randomId(6)}`.toLowerCase() |
| 937 | const p = `pw-${randomId(8)}` |
| 938 | const adminReq = { auth, jar: {} } |
| 939 | try { |
| 940 | await reqApi('add_account', { username: u, password: p, allow_net: '192.0.2.1' }, |
| 941 | res => res?.username === u, adminReq)() |
| 942 | const jar = {} |
| 943 | // cache the current account mask before presenting credentials for another account |
| 944 | await reqApi('refresh_session', {}, res => res?.username === username, { auth, jar })() |
| 945 | await reqApi('refresh_session', {}, res => res?.username === username, { jar })() |
| 946 | await reqApi('refresh_session', {}, res => { |
| 947 | if (res?.username) |
| 948 | throw Error(`account switch bypassed allow_net as ${res.username}`) |
| 949 | }, { auth: `${u}:${p}`, jar })() |
| 950 | } |
| 951 | finally { |
| 952 | await reqApi('del_account', { username: u }, 200, adminReq)().catch(() => {}) |
| 953 | } |
| 954 | }) |
| 955 | test('auto_login_net.canLogin', async () => { |
| 956 | const user = `auto-login-${randomId(6)}`.toLowerCase() |
| 957 | const adminReq = { auth, jar: {} } |
| 958 | try { |
| 959 | await reqApi('add_account', { username: user, overwrite: true, auto_login_net: '::1' }, res => res?.username === user, adminReq)() |
| 960 | await reqApi('refresh_session', {}, res => res?.username === user, { jar: {} })() |
| 961 | await reqApi('refresh_session', {}, res => !res?.username, { baseUrl: BASE_URL_127, jar: {} })() |
| 962 | } |
| 963 | finally { |
| 964 | await reqApi('del_account', { username: user }, 200, adminReq)().catch(() => {}) |
| 965 | } |
| 966 | }) |
| 967 | test('change_srp enforces self/admin permissions', async () => { |
| 968 | const selfUser = `change-srp-self-${randomId(6)}`.toLowerCase() |
| 969 | const otherUser = `change-srp-other-${randomId(6)}`.toLowerCase() |
| 970 | const selfPwd = `pw-${randomId(8)}` |
| 971 | const adminReq = { auth, jar: {} } |
| 972 | try { |
| 973 | await reqApi('add_account', { username: selfUser, overwrite: true, password: selfPwd }, res => res?.username === selfUser, adminReq)() |
| 974 | await reqApi('add_account', { username: otherUser, overwrite: true, password: randomId(8) }, res => res?.username === otherUser, adminReq)() |
| 975 | |
| 976 | const selfChange = await makeSrpChange(selfUser) |
| 977 | const jar = {} |
| 978 | await reqApi('change_srp', selfChange, 401, { jar })() // no account |
| 979 | await reqApi('refresh_session', {}, res => res?.username === selfUser, { jar, auth: `${selfUser}:${selfPwd}` })() |
| 980 | await reqApi('change_srp', selfChange, 200, { jar })() // my account |
| 981 | const otherChange = await makeSrpChange(otherUser) |
| 982 | await reqApi('change_srp', otherChange, 401, { jar })() // another account but no admin |
| 983 | await reqApi('change_srp', otherChange, 200, adminReq)() // another account and i'm admin |
| 984 | } |
| 985 | finally { |
| 986 | await reqApi('del_account', { username: [selfUser, otherUser] }, 200, adminReq)().catch(() => {}) |
| 987 | } |
| 988 | |
| 989 | async function makeSrpChange(username: string, password=`next-${randomId(8)}`) { |
| 990 | const res = await srp.createVerifierAndSalt(srp6aNimbusRoutines, username, password) |
| 991 | return { salt: String(res.s), verifier: String(res.v), username } |
| 992 | } |
| 993 | }) |
| 994 | }) |
| 995 | |
| 996 | describe('accounts', () => { |
| 997 | before(() => login(username)) |
| 998 | test('get_accounts', reqApi('get_accounts', {}, ({ list }) => _.find(list, { username }) && _.find(list, { username: 'admins' }))) |
| 999 | const add = 'test-Add' |
| 1000 | test('accounts.add', reqApi('add_account', { username: add, overwrite: true }, res => res?.username === add.toLowerCase())) |
| 1001 | test('accounts.remove', reqApi('del_account', { username: add }, 200)) |
| 1002 | test('accounts.remove array', reqApi('del_account', { username: [add] }, x => x.errors[add] === 404)) |
| 1003 | }) |
| 1004 | |
| 1005 | describe('after-login', () => { |
| 1006 | before(() => login(username)) |
| 1007 | const trickyChars = '%strange#' |
| 1008 | test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: UPLOAD_DIR }, 200)) |
| 1009 | test('create_folder.empty name', reqApi('create_folder', { uri: UPLOAD_ROOT, name: '' }, 400)) |
| 1010 | test('create_folder.tricky chars', async () => { |
| 1011 | await reqApi('create_folder', { uri: UPLOAD_ROOT, name: trickyChars }, 200)() |
| 1012 | const dest = resolve(UPLOAD_DISK_ROOT, trickyChars) |
| 1013 | await access(dest) |
| 1014 | await rm(dest, { recursive: true }) |
| 1015 | }) |
| 1016 | test('inherit.perm', reqList('/for-admins/', { inList:['alfa.txt'] })) |
| 1017 | test('inherit.disabled', reqList('/for-disabled/', 401)) |
| 1018 | test('rename.to existing folder', async () => { |
| 1019 | const from = 'rename' |
| 1020 | const dest = 'cant-overwrite' |
| 1021 | const baseDir = await ensureCantOverwriteDir() |
| 1022 | const fromPath = resolve(baseDir, from) |
| 1023 | const destPath = resolve(baseDir, dest) |
| 1024 | await writeFile(fromPath, 'from') |
| 1025 | await writeFile(destPath, 'dest') |
| 1026 | try { await reqApi('rename', { uri: CANT_OVERWRITE_URI + from, dest }, 403)() } |
| 1027 | finally { |
| 1028 | await rmAny(baseDir) |
| 1029 | } |
| 1030 | }) |
| 1031 | test('upload.never', reqUpload('/random', 403)) |
| 1032 | test('upload.ok', reqUpload(UPLOAD_DEST, 200)) |
| 1033 | test('move.dest is file', reqApi('move_files', { uri_from: [UPLOAD_DEST], uri_to: UPLOAD_DEST }, 405)) |
| 1034 | test('upload.dot name', reqUpload(`${UPLOAD_ROOT}%2e`, 418)) |
| 1035 | test('upload.unreadable', reqUpload(`${UPLOAD_ROOT}%0a`, 418)) |
| 1036 | test('upload.bad encoding', reqUpload(`${UPLOAD_ROOT}%E0%A4%A`, 404)) |
| 1037 | test('upload.temp hash traversal', req(`${UPLOAD_ROOT}%2e%2e?get=${UPLOAD_TEMP_HASH}`, 404)) |
| 1038 | test('upload.temp hash requires auth', async () => { |
| 1039 | const rel = `${UPLOAD_DIR}/partial.png` |
| 1040 | await reqUpload(`${UPLOAD_ROOT}${rel}?partial=1`, 204)() |
| 1041 | await req(`${UPLOAD_ROOT}${rel}?get=${UPLOAD_TEMP_HASH}`, 401, { jar: {} })() |
| 1042 | }) |
| 1043 | test('upload.temp hash missing', req(`${UPLOAD_ROOT}${UPLOAD_DIR}/missing.png?get=${UPLOAD_TEMP_HASH}`, 404)) |
| 1044 | test('upload.numbered', async () => { |
| 1045 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/put-plain-${randomId(6)}.txt` |
| 1046 | const res: any = {} |
| 1047 | try { |
| 1048 | res.first = await reqUpload(uri, x => x?.uri === uri, 'some')() |
| 1049 | res.second = await reqUpload(uri, x => x?.uri !== uri, 'more')() // this will be numbered to not overwrite |
| 1050 | } |
| 1051 | finally { |
| 1052 | await rmAny(uploadUriToPath(res.first?.uri)) |
| 1053 | await rmAny(uploadUriToPath(res.second?.uri)) |
| 1054 | } |
| 1055 | }) |
| 1056 | test('file_details.admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => { |
| 1057 | const u = res?.details?.[0]?.upload |
| 1058 | throwIf(!u?.ip ? 'ip' : u?.username !== username ? 'username' : '') |
| 1059 | })) |
| 1060 | test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, noVisibleDetails, { jar: {} })) |
| 1061 | test('percent name apis.details', async () => { |
| 1062 | const percentName = `x%25-${randomId(4)}` |
| 1063 | const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}` |
| 1064 | const comment = `note-${randomId(6)}` |
| 1065 | await reqUpload(percentUri, 200)() |
| 1066 | try { |
| 1067 | await reqApi('get_file_details', { uris: [percentUri] }, res => !!res?.details?.[0]?.upload)() |
| 1068 | await reqApi('comment', { uri: percentUri, comment }, 200)() |
| 1069 | await reqApi('get_file_list', { uri: UPLOAD_ROOT }, res => _.find(res?.list, { n: percentName })?.comment === comment)() |
| 1070 | await reqApi('get_folder_size', { uri: percentUri }, 405)() |
| 1071 | } |
| 1072 | finally { |
| 1073 | await req(percentUri, 200, { method: 'delete' })().catch(() => {}) |
| 1074 | await rmAny(resolve(UPLOAD_DISK_ROOT, percentName)) |
| 1075 | } |
| 1076 | }) |
| 1077 | |
| 1078 | test('percent name apis.rename', async () => { |
| 1079 | const percentName = `x%25-${randomId(4)}` |
| 1080 | const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}` |
| 1081 | const renameName = `${percentName}-renamed` |
| 1082 | const renamedUri = `${UPLOAD_ROOT}${pathEncode(renameName)}` |
| 1083 | await reqUpload(percentUri, 200)() |
| 1084 | try { |
| 1085 | await reqApi('rename', { uri: percentUri, dest: renameName }, 200)() |
| 1086 | await req(percentUri, 404)() |
| 1087 | await req(renamedUri, 200)() |
| 1088 | } |
| 1089 | finally { |
| 1090 | await req(renamedUri, 200, { method: 'delete' })().catch(() => {}) |
| 1091 | await rmAny(resolve(UPLOAD_DISK_ROOT, renameName)) |
| 1092 | } |
| 1093 | }) |
| 1094 | |
| 1095 | test('percent name apis.move-copy', async () => { |
| 1096 | const percentName = `x%25-${randomId(4)}` |
| 1097 | const percentUri = `${UPLOAD_ROOT}${pathEncode(percentName)}` |
| 1098 | const folderName = `pct-${randomId(6)}` |
| 1099 | const folderUri = `${UPLOAD_ROOT}${folderName}/` |
| 1100 | const movedUri = `${UPLOAD_ROOT}${folderName}/${pathEncode(percentName)}` |
| 1101 | await reqUpload(percentUri, 200)() |
| 1102 | try { |
| 1103 | await reqApi('create_folder', { uri: UPLOAD_ROOT, name: folderName }, 200)() |
| 1104 | await reqApi('move_files', { uri_from: [percentUri], uri_to: folderUri }, res => !res?.errors?.[0])() |
| 1105 | await req(movedUri, 200)() |
| 1106 | await reqApi('copy_files', { uri_from: [movedUri], uri_to: UPLOAD_ROOT }, res => !res?.errors?.[0])() |
| 1107 | await req(percentUri, 200)() |
| 1108 | } |
| 1109 | finally { |
| 1110 | await req(percentUri, 200, { method: 'delete' })().catch(() => {}) |
| 1111 | await req(movedUri, 200, { method: 'delete' })().catch(() => {}) |
| 1112 | await rmAny(resolve(UPLOAD_DISK_ROOT, percentName)) |
| 1113 | await rmAny(resolve(UPLOAD_DISK_ROOT, folderName, percentName)) |
| 1114 | await rmAny(resolve(UPLOAD_DISK_ROOT, folderName)) |
| 1115 | } |
| 1116 | }) |
| 1117 | test('zip.no-list but archive', req('/zipNoList/?get=zip', 403, { jar: {} })) |
| 1118 | test('upload but not delete', async () => { |
| 1119 | const name = `cant-delete` |
| 1120 | await mkdir(resolve(UPLOAD_DISK_ROOT, name), { recursive: true }) |
| 1121 | await reqApi('add_vfs', { parent: UPLOAD_ROOT, source: `../tmp/${name}`, name, can_upload: ['admins'], can_delete: false }, 200)() |
| 1122 | try { |
| 1123 | const dest = `${UPLOAD_ROOT}${name}/no-delete.txt` |
| 1124 | await reqUpload(dest, 200)() |
| 1125 | await req(dest, 403, { method: 'delete' })() |
| 1126 | } |
| 1127 | finally { |
| 1128 | await reqApi('del_vfs', { uris: [UPLOAD_ROOT + name] }, 200)().catch(() => {}) |
| 1129 | await rmAny(resolve(UPLOAD_DISK_ROOT, name)) |
| 1130 | } |
| 1131 | }) |
| 1132 | test('move.overwrite needs delete', async () => { |
| 1133 | const destFile = 'locked.txt' |
| 1134 | const destDir = await ensureCantOverwriteDir() |
| 1135 | const destPath = resolve(destDir, destFile) |
| 1136 | const sourceUri = `${UPLOAD_ROOT}${UPLOAD_DIR}/${destFile}` |
| 1137 | await writeFile(destPath, 'dest') |
| 1138 | try { |
| 1139 | await reqUpload(sourceUri, 200, 'source')() |
| 1140 | const before = statSync(destPath).size |
| 1141 | await reqApi('move_files', { uri_from: [sourceUri], uri_to: CANT_OVERWRITE_URI }, res => res?.errors?.[0] === 403)() |
| 1142 | const after = statSync(destPath).size |
| 1143 | if (after !== before) |
| 1144 | throw "file overwritten" |
| 1145 | } |
| 1146 | finally { |
| 1147 | await rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR, destFile)) |
| 1148 | await rmAny(destDir) |
| 1149 | } |
| 1150 | }) |
| 1151 | test('upload.path bypass', async () => { |
| 1152 | const name = 'no-upload' |
| 1153 | const targetDir = resolve(UPLOAD_DISK_ROOT, name) |
| 1154 | try { |
| 1155 | await execP(`curl -g -s -u ${auth} -F "upload=@${SAMPLE_FILE_PATH};filename=${name}/evil.txt" ${BASE_URL}${UPLOAD_ROOT}`) |
| 1156 | if (existsSync(resolve(targetDir, 'evil.txt'))) |
| 1157 | throw "file created" |
| 1158 | } |
| 1159 | finally { |
| 1160 | await rmAny(targetDir) |
| 1161 | } |
| 1162 | }) |
| 1163 | test('upload.existing.skip', async () => { |
| 1164 | const filePath = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE) |
| 1165 | const before = statSync(filePath).size |
| 1166 | await reqUpload(UPLOAD_DEST + '?existing=skip', 409)() |
| 1167 | const after = statSync(filePath).size |
| 1168 | if (after !== before) |
| 1169 | throw "size changed" |
| 1170 | }) |
| 1171 | test('upload.crossing', reqUpload(UPLOAD_DEST.replace(UPLOAD_DIR, '../..'), 404)) |
| 1172 | test('upload.overlap', async () => { |
| 1173 | const ms = 300 |
| 1174 | const first = reqUpload(UPLOAD_DEST, 200, makeReadableThatTakes(ms))() |
| 1175 | await wait(ms/3) |
| 1176 | await reqUpload(UPLOAD_DEST, 409)() // should conflict |
| 1177 | await first |
| 1178 | }) |
| 1179 | test('upload.concurrent', { timeout: 5000 }, () => Promise.all([ |
| 1180 | reqUpload(UPLOAD_DEST, 200, new StringRepeaterStream(BIG_CONTENT, 150))(), // 300MB |
| 1181 | ..._.range(3).map(i => reqUpload(UPLOAD_DEST + i, 200, new StringRepeaterStream(BIG_CONTENT, 50))()) // 3 x 100MB |
| 1182 | ]).then(() => {})) |
| 1183 | test('upload.interrupted', async () => { |
| 1184 | const fn = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE.replace('/', '/hfs$upload-')) |
| 1185 | await rm(fn, {force: true}) |
| 1186 | const neededTime = 600 |
| 1187 | const makeAbortedRequest = (afterMs: number) => { |
| 1188 | const r = reqUpload(UPLOAD_DEST + '?supposedToAbort' /*to recognize in the logs*/, 0, makeReadableThatTakes(neededTime))() |
| 1189 | setTimeout(r.abort, afterMs) |
| 1190 | return r.catch(() => {}) // wait for it to fail |
| 1191 | .then(() => wait(500)) // aborted requests don't guarantee that the server has finished and released the file, so we wait some arbitrary time |
| 1192 | } |
| 1193 | const timeFirstRequest = neededTime * .5 // not enough to finish |
| 1194 | await makeAbortedRequest(timeFirstRequest) |
| 1195 | const getTempSize = () => try_(() => statSync(fn)?.size) |
| 1196 | const size = getTempSize() |
| 1197 | if (!size) // temp file is left, not empty |
| 1198 | throw "missing temp file" |
| 1199 | await reqUpload(UPLOAD_DEST + '?resume=0!', 412)() |
| 1200 | await makeAbortedRequest(timeFirstRequest * 1.5) // upload more than r1 |
| 1201 | if (!(size < getTempSize()!)) // should be increased, as secondary temp file got bigger and replaced primary one |
| 1202 | throw `temp file not enlarged, it was ${size} and now it's ${getTempSize()}` |
| 1203 | await reqUpload(UPLOAD_DEST, 200, makeReadableThatTakes(0))() // quickly complete the upload, and check for final size |
| 1204 | if (getTempSize()) |
| 1205 | throw "temp file should be cleared" |
| 1206 | // test resume |
| 1207 | await makeAbortedRequest(timeFirstRequest) |
| 1208 | const partial = getTempSize() |
| 1209 | if (!partial) |
| 1210 | throw "partial file missing" |
| 1211 | await reqUpload(UPLOAD_DEST, 200, Readable.from(BIG_CONTENT.slice(partial)), BIG_CONTENT.length, partial)() |
| 1212 | }) |
| 1213 | test('rename.backslash', async () => { |
| 1214 | await reqApi('rename', { uri: UPLOAD_DEST, dest: 'sub\\file' }, process.platform === 'win32' ? 403 : 200)() |
| 1215 | const d = resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR) |
| 1216 | await rename(resolve(d, 'sub\\file'), resolve(d, basename(UPLOAD_DEST))).catch(() => {}) |
| 1217 | }) |
| 1218 | const renameTo = 'z' |
| 1219 | test('rename.ok', reqApi('rename', { uri: UPLOAD_DEST, dest: renameTo }, 200)) |
| 1220 | test('delete.miss renamed', req(UPLOAD_DEST, 404, { method: 'delete' })) |
| 1221 | test('delete.ok', async () => { |
| 1222 | const fn = resolve(UPLOAD_DISK_ROOT, dirname(UPLOAD_RELATIVE), renameTo) |
| 1223 | if (!existsSync(fn)) |
| 1224 | throw "missing file" |
| 1225 | await req(dirname(UPLOAD_DEST) + '/' + renameTo, 200, { method: 'delete' })() |
| 1226 | if (existsSync(fn)) |
| 1227 | throw "not deleted" |
| 1228 | }) |
| 1229 | test('reupload', reqUpload(UPLOAD_DEST, 200)) |
| 1230 | test('delete.method', req(UPLOAD_DEST, 200, { method: 'DELETE' })) |
| 1231 | test('delete.miss deleted', req(UPLOAD_DEST, 404, { method: 'delete' })) |
| 1232 | test('rename.tricky chars', async () => { |
| 1233 | const dest = trickyChars |
| 1234 | await mkdir(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR), { recursive: true }) |
| 1235 | const fn = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE) |
| 1236 | await writeFile(fn, 'z') |
| 1237 | try { |
| 1238 | await reqApi('rename', { uri: UPLOAD_DEST, dest }, 200)() // dest is not encoded |
| 1239 | await reqApi('rename', { uri: dirname(UPLOAD_DEST) + '/' + pathEncode(dest), dest: basename(UPLOAD_DEST) }, 200)() |
| 1240 | } |
| 1241 | finally { await rm(fn) } |
| 1242 | }) |
| 1243 | const declaredSize = BIG_CONTENT.length / 2 |
| 1244 | test('upload.too much', reqUpload(UPLOAD_DEST, (x,res)=> { |
| 1245 | if (res.statusCode === 400) return // status 400 is caused by nodejs itself, intercepting the mismatch, but it's probably an unreliable race condition |
| 1246 | if (res.statusCode !== 200) // it happened sometimes that node didn't block (can't replicate). In such case we should get a 200 with a file the size of declaredSize. |
| 1247 | throw `expected 200, got ${res.statusCode}` |
| 1248 | const size = try_(() => statSync(resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE)).size) |
| 1249 | if (size !== declaredSize) |
| 1250 | throw `expected ${declaredSize}, got ${size}` |
| 1251 | }, BIG_CONTENT, declaredSize)) |
| 1252 | test('upload.free space', async () => { |
| 1253 | const res = statfsSync(ROOT) |
| 1254 | const free = res.bavail * res.bsize |
| 1255 | const fakeSize = Math.round(free * 0.51) |
| 1256 | const r1 = reqUpload(`${UPLOAD_ROOT}${UPLOAD_DIR}/free1`, 400, makeReadableThatTakes(1000), fakeSize)() |
| 1257 | setTimeout(r1.abort, 1500) |
| 1258 | await Promise.all([ |
| 1259 | r1.catch(() => {}), |
| 1260 | wait(100).then(() => reqUpload(`${UPLOAD_ROOT}${UPLOAD_DIR}/free2`, 507, makeReadableThatTakes(500), fakeSize)()) |
| 1261 | ]) |
| 1262 | }) |
| 1263 | test('max_dl.account', async () => { |
| 1264 | const uri = `${UPLOAD_ROOT}${UPLOAD_DIR}/big` |
| 1265 | await reqUpload(uri, 200, BIG_CONTENT)() |
| 1266 | await testMaxDl(uri, 2, 1) |
| 1267 | }) |
| 1268 | test('logout', async () => { |
| 1269 | await reqApi('get_accounts', {}, 200)() // we're admin |
| 1270 | await reqApi('logout', {}, 401)() |
| 1271 | await reqApi('get_accounts', {}, 401)() // no more |
| 1272 | }) |
| 1273 | after(() => rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR))) |
| 1274 | }) |
| 1275 | |
| 1276 | describe('admin', () => { |
| 1277 | test('add folder', async () => { |
| 1278 | const name = 'added' |
| 1279 | try { |
| 1280 | await reqApi('add_vfs', { source: '.', name, can_see: { this: false, children: true } }, 200, { auth })() // add an invisible folder |
| 1281 | await reqList(name, { inList: ['plugins/'] })() |
| 1282 | } |
| 1283 | finally { |
| 1284 | await reqApi('del_vfs', { uris: ['/'+name] }, data => data?.errors?.[0] === 0, { auth })() // remove |
| 1285 | } |
| 1286 | }) |
| 1287 | test('add_vfs source without name', async () => { |
| 1288 | const res = await reqApi('add_vfs', { source: '.' }, 200, { auth })() |
| 1289 | const name = res?.name |
| 1290 | if (typeof name !== 'string' || !name) |
| 1291 | throw "missing name" |
| 1292 | await reqApi('del_vfs', { uris: ['/' + name] }, data => [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {}) |
| 1293 | }) |
| 1294 | test('account rename updates nested VFS permissions', async () => { |
| 1295 | const oldUsername = `vfs-old-${randomId(6)}`.toLowerCase() |
| 1296 | const newUsername = `vfs-new-${randomId(6)}`.toLowerCase() |
| 1297 | const name = `vfs-account-${randomId(6)}` |
| 1298 | try { |
| 1299 | await reqApi('add_account', { username: oldUsername }, 200, { auth })() |
| 1300 | await reqApi('add_vfs', { |
| 1301 | source: '.', |
| 1302 | name, |
| 1303 | can_read: { this: [oldUsername], children: [oldUsername] }, |
| 1304 | }, 200, { auth })() |
| 1305 | await reqApi('set_account', { username: oldUsername, changes: { username: newUsername } }, 200, { auth })() |
| 1306 | await reqApi('get_vfs', {}, res => { |
| 1307 | const permission = _.find(res?.root?.children, { name })?.can_read |
| 1308 | throwIf(!_.isEqual(permission, { this: [newUsername], children: [newUsername] }) |
| 1309 | ? 'nested VFS permission not updated' : '') |
| 1310 | }, { auth })() |
| 1311 | } |
| 1312 | finally { |
| 1313 | await reqApi('del_vfs', { uris: ['/' + name] }, 200, { auth })().catch(() => {}) |
| 1314 | await reqApi('del_account', { username: [newUsername, oldUsername] }, 200, { auth })().catch(() => {}) |
| 1315 | } |
| 1316 | }) |
| 1317 | test('set_vfs.rename and props', async () => { |
| 1318 | const name = `set vfs ${randomId(6)}` |
| 1319 | const renamed = `${name}-renamed` |
| 1320 | const uri = '/' + name |
| 1321 | const renamedUri = '/' + renamed |
| 1322 | const rootsHost = `set-vfs-${randomId(6)}.example.com` |
| 1323 | const oldRoots = await reqApi('get_config', { only: ['roots'] }, 200, { auth })().then(res => res.roots) |
| 1324 | try { |
| 1325 | await reqApi('add_vfs', { source: '.', name }, 200, { auth })() |
| 1326 | await reqApi('set_config', { values: { roots: { ...oldRoots, [rootsHost]: uri } } }, 200, { auth })() |
| 1327 | await reqApi('set_vfs', { uri, props: { name: renamed, comment: 'test note', can_list: false } }, 200, { auth })() |
| 1328 | await reqApi('get_vfs', {}, res => { |
| 1329 | const children = res?.root?.children || [] |
| 1330 | const oldNode = _.find(children, { name }) |
| 1331 | const renamedNode = _.find(children, { name: renamed }) |
| 1332 | throwIf(oldNode ? 'old node still present' |
| 1333 | : !renamedNode ? 'renamed node missing' |
| 1334 | : renamedNode.comment !== 'test note' ? 'comment not updated' |
| 1335 | : renamedNode.can_list !== false ? 'can_list not updated' : '') |
| 1336 | }, { auth })() |
| 1337 | await reqApi('get_config', { only: ['roots'] }, res => |
| 1338 | throwIf(res?.roots?.[rootsHost] === renamedUri + '/' ? '' : 'root not updated'), { auth })() |
| 1339 | } |
| 1340 | finally { |
| 1341 | await reqApi('set_config', { values: { roots: oldRoots } }, 200, { auth })().catch(() => {}) |
| 1342 | await reqApi('del_vfs', { uris: [renamedUri] }, data => |
| 1343 | [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {}) |
| 1344 | await reqApi('del_vfs', { uris: [uri] }, data => |
| 1345 | [0, 404].includes(data?.errors?.[0]), { auth })().catch(() => {}) |
| 1346 | } |
| 1347 | }) |
| 1348 | test('del_vfs.bad uris', reqApi('del_vfs', { uris: ['', '/', '//'] }, (res: any) => |
| 1349 | throwIf(res?.errors.some((x: any) => x === 406) ? '' : res?.errors || 'missing'), { auth })) |
| 1350 | test('plugins.missing', reqApi('set_plugin', { id: 'missing-plugin', enabled: true }, { status: 400, re: /miss/ }, { auth })) |
| 1351 | test('plugins.update.missing', reqApi('update_plugin', { id: 'missing-plugin' }, 404, { auth })) |
| 1352 | test('monitor.connections safe path decode', async () => { |
| 1353 | const body = makeReadableThatTakes(1000) |
| 1354 | const size = body.length |
| 1355 | const rawName = '%2' |
| 1356 | const uploadPromise = reqUpload(`${UPLOAD_ROOT}${pathEncode(rawName)}`, () => true, body, size)() |
| 1357 | try { |
| 1358 | await wait(200) |
| 1359 | const res = await readEventStreamOnce(`${API}get_connections`, { auth }) |
| 1360 | await uploadPromise |
| 1361 | if (res.status !== 200) |
| 1362 | throw `unexpected status ${res.status}` |
| 1363 | } |
| 1364 | finally { await rmAny(resolve(UPLOAD_DISK_ROOT, rawName)) } |
| 1365 | }) |
| 1366 | test('monitor.connections upload path decodes colon folder', async () => { |
| 1367 | const body = makeReadableThatTakes(700) |
| 1368 | const size = body.length |
| 1369 | const folderName = `colon:${randomId(4)}` |
| 1370 | const fileName = 'slow-upload.txt' |
| 1371 | const uploadPromise = reqUpload(`${UPLOAD_ROOT}${pathEncode(folderName)}/${fileName}`, () => true, body, size, 0, { auth })() |
| 1372 | try { |
| 1373 | await wait(200) |
| 1374 | const res = await readEventStreamOnce(`${API}get_connections`, { auth }) |
| 1375 | await uploadPromise |
| 1376 | const expectedPath = `${UPLOAD_ROOT}${folderName}/${fileName}` |
| 1377 | const encodedPath = `${UPLOAD_ROOT}${pathEncode(folderName)}/${fileName}` |
| 1378 | if (!res.data.includes(expectedPath)) |
| 1379 | throw Error('missing decoded upload path: ' + res.data) |
| 1380 | if (res.data.includes(encodedPath)) |
| 1381 | throw Error('upload path still encoded: ' + res.data) |
| 1382 | } |
| 1383 | finally { await rmAny(resolve(UPLOAD_DISK_ROOT, folderName)) } |
| 1384 | }) |
| 1385 | test('plugins.start_stop', async () => { |
| 1386 | const id = 'download-counter' |
| 1387 | await reqApi('stop_plugin', { id }, 200, { auth })() |
| 1388 | await reqApi('start_plugin', { id }, 200, { auth })() |
| 1389 | await reqApi('stop_plugin', { id }, res => { |
| 1390 | if (res?.msg === 'already stopped') |
| 1391 | throw "plugin didn't start" |
| 1392 | }, { auth })() |
| 1393 | }) |
| 1394 | test('plugins.dirEntry event', async () => { |
| 1395 | const script = `exports.init = api => api.events.on('dirEntry', ({ entry }) => entry.n === 'f2/' && api.events.stop)` |
| 1396 | await switchIt(true).finally(() => switchIt(false).catch(() => {})) |
| 1397 | |
| 1398 | async function switchIt(on: boolean) { |
| 1399 | let lastNames: any |
| 1400 | await reqApi('set_config', { values: { server_code: on ? script : '' } }, 200, { auth })() |
| 1401 | const good = await waitFor(async () => { |
| 1402 | const res = await reqList('/f1/', { status: 200 })() |
| 1403 | lastNames = res?.list?.map((x: any) => x.n) |
| 1404 | return on === !isInList(res, 'f2/') |
| 1405 | }, { interval: 100, timeout: 3000 }) |
| 1406 | if (!good) |
| 1407 | throw Error("condition not met on list: " + JSON.stringify(lastNames)) |
| 1408 | } |
| 1409 | }) |
| 1410 | test('plugins.download-counter percent name', async () => { |
| 1411 | const id = 'download-counter' |
| 1412 | await reqApi('start_plugin', { id }, 200, { auth })() |
| 1413 | try { |
| 1414 | const before = await getHits() |
| 1415 | await req(FUNNY_NAME_ENCODED, 200)() |
| 1416 | let after = 0 |
| 1417 | for (const _x of _.range(10)) { |
| 1418 | await wait(100) |
| 1419 | after = await getHits() |
| 1420 | if (after > before) |
| 1421 | break |
| 1422 | } |
| 1423 | if (after <= before) |
| 1424 | throw `counter not incremented (before ${before}, after ${after})` |
| 1425 | } |
| 1426 | finally { |
| 1427 | await reqApi('stop_plugin', { id }, 200, { auth })() |
| 1428 | } |
| 1429 | |
| 1430 | async function getHits() { |
| 1431 | const listRes = await reqList('/', { status: 200 })() |
| 1432 | const entry = _.find(listRes?.list, { n: FUNNY_NAME }) |
| 1433 | if (!entry) |
| 1434 | throw "missing entry in list" |
| 1435 | return entry.hits || 0 |
| 1436 | } |
| 1437 | }) |
| 1438 | test('plugins.public traversal', async () => { |
| 1439 | const id = 'list-uploader' |
| 1440 | await reqApi('start_plugin', { id }, 200, { auth })() |
| 1441 | return req(`/~/plugins/${id}/../../../tests/config.yaml`, 404)() |
| 1442 | .finally(() => reqApi('stop_plugin', { id }, 200, { auth })()) |
| 1443 | }) |
| 1444 | const antibruteCfg = { |
| 1445 | increment: 1, max: 60, |
| 1446 | blockAfter: 9999, maxQueuePerIp: 128, |
| 1447 | maxQueuePerAccount: 128, maxQueueGlobal: 512, |
| 1448 | } |
| 1449 | test('antibrute.valid basic auth has no progressive delay', async () => { |
| 1450 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1451 | const first = await reqBasicAuth('/for-admins/', auth) |
| 1452 | const second = await reqBasicAuth('/for-admins/', auth) |
| 1453 | if (first.status !== 200) throw "first request failed" |
| 1454 | if (second.status !== 200) throw "second request failed" |
| 1455 | if (first.delay !== 0) throw "first request delayed" |
| 1456 | if (second.delay !== 0) throw "second request delayed" |
| 1457 | }) |
| 1458 | }) |
| 1459 | test('antibrute.valid burst has no anti-brute delay', async () => { |
| 1460 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1461 | const burst = await Promise.all(_.times(20, () => reqBasicAuth('/for-admins/', auth))) |
| 1462 | if (burst.some(x => x.status !== 200)) throw `unexpected statuses in valid burst: ${burst.map(x => x.status)}` |
| 1463 | if (burst.some(x => x.delay !== 0)) throw `unexpected delay in valid burst: ${burst.map(x => x.delay)}` |
| 1464 | }) |
| 1465 | }) |
| 1466 | test('antibrute.valid burst x100 has no anti-brute delay', async () => { |
| 1467 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1468 | const burst = await Promise.all(_.times(100, () => reqBasicAuth('/for-admins/', auth))) |
| 1469 | if (burst.some(x => x.status !== 200)) throw `unexpected statuses in x100 valid burst: ${burst.map(x => x.status)}` |
| 1470 | if (burst.some(x => x.delay !== 0)) throw `unexpected delay in x100 valid burst: ${burst.map(x => x.delay)}` |
| 1471 | }) |
| 1472 | }) |
| 1473 | test('antibrute.failed basic auth escalates delay', async () => { |
| 1474 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1475 | const first = await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1476 | const second = await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1477 | if (first.status !== 401) throw "first wrong login was not rejected" |
| 1478 | if (second.status !== 401) throw "second wrong login was not rejected" |
| 1479 | if (second.delay < 500) throw `missing delay escalation: ${second.delay}` |
| 1480 | }) |
| 1481 | }) |
| 1482 | test('antibrute.failed loginSrp1 escalates delay', async () => { |
| 1483 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1484 | const user = `missing-srp-${randomId(6)}` |
| 1485 | const first = await reqLoginSrp1(user) |
| 1486 | if (first.status !== 200) throw "unknown srp login was rejected at step 1" |
| 1487 | const repeated = await reqLoginSrp1(user) |
| 1488 | if (first.salt !== repeated.salt) throw "unknown srp salt was not stable" |
| 1489 | await login(user).then(() => { throw "unknown srp login succeeded" }, () => {}) |
| 1490 | const second = await reqLoginSrp1(user) |
| 1491 | if (second.status !== 200) throw "unknown srp login was rejected at step 1" |
| 1492 | if (second.delay < 500) throw `missing srp delay escalation: ${second.delay}` |
| 1493 | }) |
| 1494 | }) |
| 1495 | test('antibrute.valid loginSrp1 does not count as failed login', async () => { |
| 1496 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1497 | const first = await reqLoginSrp1(username) |
| 1498 | const second = await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1499 | if (first.status !== 200) throw "valid srp step1 was rejected" |
| 1500 | if (first.delay !== 0) throw `valid srp step1 was delayed: ${first.delay}` |
| 1501 | if (second.status !== 401) throw "wrong login was not rejected" |
| 1502 | if (second.delay !== 0) throw `valid srp step1 was counted as failed login: ${second.delay}` |
| 1503 | }) |
| 1504 | }) |
| 1505 | test('antibrute.successful login resets penalty', async () => { |
| 1506 | await withPluginConfig('antibrute', antibruteCfg, async () => { |
| 1507 | await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1508 | const penalized = await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1509 | const success = await reqBasicAuth('/for-admins/', auth) |
| 1510 | const afterReset = await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1511 | if (penalized.status !== 401) throw "penalized wrong login status mismatch" |
| 1512 | if (penalized.delay < 500) throw `missing pre-reset delay: ${penalized.delay}` |
| 1513 | if (success.status !== 200) throw "successful login failed" |
| 1514 | if (afterReset.status !== 401) throw "post-reset wrong login status mismatch" |
| 1515 | if (afterReset.delay !== 0) throw `delay not reset after successful login: ${afterReset.delay}` |
| 1516 | }) |
| 1517 | }) |
| 1518 | test('antibrute.burst serializes wrong logins with delays', async () => { |
| 1519 | await withPluginConfig('antibrute', { |
| 1520 | ...antibruteCfg, |
| 1521 | increment: 1, |
| 1522 | max: 1, |
| 1523 | maxQueuePerIp: 3, |
| 1524 | maxQueuePerAccount: 3, |
| 1525 | maxQueueGlobal: 3, |
| 1526 | }, async () => { |
| 1527 | // seed penalty so the first burst request keeps queue slots busy |
| 1528 | await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1529 | const started = Date.now() |
| 1530 | const burst = await Promise.all(_.times(3, () => reqBasicAuth('/for-admins/', `${username}:wrong-password`))) |
| 1531 | const elapsed = Date.now() - started |
| 1532 | const delays = burst.map(x => x.delay) |
| 1533 | if (burst.some(x => x.status !== 401)) throw `unexpected statuses in burst: ${burst.map(x => x.status)}` |
| 1534 | if (delays.some(x => x <= 0)) throw `missing delay in burst: ${delays.join(',')}` |
| 1535 | // the wall clock check proves requests waited in series instead of sharing one penalty window |
| 1536 | if (elapsed < 2500) throw `burst was not serialized: ${elapsed}` |
| 1537 | }) |
| 1538 | }) |
| 1539 | test('antibrute.queue limit rejects overflowing logins before credentials are checked', async () => { |
| 1540 | await withPluginConfig('antibrute', { |
| 1541 | ...antibruteCfg, |
| 1542 | increment: 1, |
| 1543 | max: 1, |
| 1544 | maxQueuePerIp: 1, |
| 1545 | maxQueuePerAccount: 1, |
| 1546 | maxQueueGlobal: 1, |
| 1547 | }, async () => { |
| 1548 | // seed penalty so the queue slot remains occupied long enough to overflow |
| 1549 | await reqBasicAuth('/for-admins/', `${username}:wrong-password`) |
| 1550 | const burst = await Promise.all(_.times(3, () => reqBasicAuth('/for-admins/', auth))) |
| 1551 | const counts = _.countBy(burst, 'status') |
| 1552 | if (counts[200] !== 1 || counts[429] !== 2) |
| 1553 | throw `unexpected queue limit statuses: ${burst.map(x => x.status)}` |
| 1554 | }) |
| 1555 | }) |
| 1556 | }) |
| 1557 | |
| 1558 | describe('logging', () => { |
| 1559 | test('security-filtered traversal reaches the error log', async () => { |
| 1560 | const logPath = resolve(__dirname, 'work/logs/access-error.log') |
| 1561 | const uri = `/f1/page/.%2e/.%2e/README.md?log-test=${randomId(8)}` |
| 1562 | const adminJar = {} |
| 1563 | await reqApi('set_config', { values: { dont_log_net: '' } }, 200, { auth, jar: adminJar })() |
| 1564 | try { |
| 1565 | await req(uri, 404, { jar: {} })() |
| 1566 | const found = await waitFor(() => |
| 1567 | existsSync(logPath) && readFileSync(logPath, 'utf8').includes(uri)) |
| 1568 | if (!found) |
| 1569 | throw Error('traversal request was not written to the error log') |
| 1570 | } |
| 1571 | finally { |
| 1572 | await reqApi('set_config', { values: { dont_log_net: '127.0.0.1|::1' } }, 200, { auth, jar: adminJar })() |
| 1573 | } |
| 1574 | }) |
| 1575 | }) |
| 1576 | |
| 1577 | function login(usr: string, pwd=password) { |
| 1578 | return srpClientSequence(srp, usr, pwd, (cmd: string, params: any) => |
| 1579 | reqApi(cmd, params, (x,res)=> res.statusCode < 400)()) |
| 1580 | } |
| 1581 | |
| 1582 | function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?: number, resume=0, options?: ReqOptions) { |
| 1583 | if (resume) |
| 1584 | dest += (dest.includes('?') ? '&' : '?') + 'resume=' + resume |
| 1585 | size ??= (body as any)?.length ?? statSync(SAMPLE_FILE_PATH).size // it's ok that Readable.length is undefined |
| 1586 | const status = (tester as any).status || tester |
| 1587 | if (status === 200) |
| 1588 | tester = { |
| 1589 | status, |
| 1590 | cb(data) { |
| 1591 | const fn = uploadUriToPath(data.uri) |
| 1592 | const stats = try_(() => statSync(fn)) |
| 1593 | if (!stats) |
| 1594 | throw "uploaded file not found: " + fn |
| 1595 | if (size !== stats.size) |
| 1596 | throw `uploaded file wrong size: ${fn} = ${stats.size.toLocaleString()} expected ${size?.toLocaleString()}` |
| 1597 | return true |
| 1598 | } |
| 1599 | } |
| 1600 | return req(dest, tester, { |
| 1601 | method: 'PUT', |
| 1602 | headers: { connection: 'close', 'content-length': size === undefined ? size : size - resume }, |
| 1603 | body: body ?? createReadStream(SAMPLE_FILE_PATH), |
| 1604 | ...options, |
| 1605 | }) |
| 1606 | } |
| 1607 | |
| 1608 | function uploadUriToPath(uri: string) { |
| 1609 | return resolve(UPLOAD_DISK_ROOT, decodeURI(uri).replace(UPLOAD_ROOT, '')) |
| 1610 | } |
| 1611 | |
| 1612 | async function testMaxDl(uri: string, good: number, bad: number, reqOptions: ReqOptions={}) { |
| 1613 | // make good+bad requests, and check results |
| 1614 | await Promise.all(_.range(good + bad).map(i => req(uri + '?' + i, (_data, res) => { |
| 1615 | if (res.statusCode === 429) { |
| 1616 | if (!bad--) |
| 1617 | throw "too many refused" |
| 1618 | return |
| 1619 | } |
| 1620 | if (res.statusCode === 200) { |
| 1621 | if (!good--) |
| 1622 | throw "too many accepted" |
| 1623 | return |
| 1624 | } |
| 1625 | throw "unexpected status " + res.statusCode |
| 1626 | }, { throttle, ...reqOptions })() )) // slow down to ensure the attempted downloads are all concurrent |
| 1627 | } |
| 1628 | |
| 1629 | type TesterFunction = ((data: any, fullResponse: any) => boolean | void) // true or void for ok, false or throw for error |
| 1630 | type Tester = number |
| 1631 | | TesterFunction |
| 1632 | | RegExp |
| 1633 | | { |
| 1634 | mime?: string |
| 1635 | status?: number |
| 1636 | re?: RegExp |
| 1637 | inList?: string[] |
| 1638 | outList?: string[] |
| 1639 | permInList?: Record<string, string> |
| 1640 | empty?: true |
| 1641 | length?: number |
| 1642 | cb?: TesterFunction |
| 1643 | } |
| 1644 | |
| 1645 | type ReqOptions = XRequestOptions & { throttle?: number, baseUrl?: string } |
| 1646 | |
| 1647 | const jar = {} |
| 1648 | |
| 1649 | function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }: ReqOptions={}) { |
| 1650 | // passing 'path' keeps it as it is, avoiding internal resolving |
| 1651 | let abortable // copy abortable interface to returned promise |
| 1652 | return () => Object.assign( |
| 1653 | (abortable = httpStream((baseUrl || defaultBaseUrl) + url, { path: url, jar, ...requestOptions })) |
| 1654 | .catch(e => { |
| 1655 | if (e.code === 'ECONNREFUSED') |
| 1656 | throw e |
| 1657 | return e.cause |
| 1658 | }) |
| 1659 | .then(process), |
| 1660 | _.pick(abortable, 'abort') |
| 1661 | ) |
| 1662 | |
| 1663 | async function process(res:any) { |
| 1664 | if (!res) |
| 1665 | return console.log('got', { res }) |
| 1666 | //console.debug('sent', requestOptions, 'got', res instanceof Error ? String(res) : [res.status]) |
| 1667 | if (test && test instanceof RegExp) |
| 1668 | test = { re:test } |
| 1669 | if (typeof test === 'number') |
| 1670 | test = { status: test } |
| 1671 | const stream = throttle ? res.pipe(new ThrottledStream(new ThrottleGroup(throttle))) : res |
| 1672 | const data = await stream2string(stream).catch(() => '') |
| 1673 | const obj = tryJson(data) |
| 1674 | if (typeof test === 'object') { |
| 1675 | let { status, mime, re, inList, outList, length, permInList } = test |
| 1676 | if (inList || outList) |
| 1677 | status ||= 200 |
| 1678 | const gotMime = res.headers?.['content-type'] |
| 1679 | const gotStatus = res.statusCode |
| 1680 | const gotLength = res.headers?.['content-length'] |
| 1681 | const err = mime && !gotMime?.startsWith(mime) && 'expected mime ' + mime + ' got ' + gotMime |
| 1682 | || status && gotStatus !== status && 'expected status ' + status + ' got ' + gotStatus |
| 1683 | || re && !re.test(data) && 'expected content '+String(re)+' got '+(data || '-empty-') |
| 1684 | || inList && !inList.every(x => isInList(obj, x)) && 'expected in list '+inList |
| 1685 | || outList && !outList.every(x => !isInList(obj, x)) && 'expected not in list '+outList |
| 1686 | || permInList && findDefined(permInList, (v, k) => { |
| 1687 | const got = _.find(obj.list, { n: k })?.p |
| 1688 | const negate = v[0] === '!' |
| 1689 | return findDefined(v.slice(negate ? 1 : 0).split(''), char => |
| 1690 | got?.includes(char) === negate ? `expected perm ${v} on ${k}, got ${got}` : undefined) |
| 1691 | }) |
| 1692 | || test.empty && data && 'expected empty body' |
| 1693 | || length !== undefined && gotLength !== String(length) && "expected content-length " + length + " got " + gotLength |
| 1694 | || test.cb?.(obj ?? data, res) === false && 'error' |
| 1695 | || '' |
| 1696 | if (err) |
| 1697 | throw Error(err) |
| 1698 | } |
| 1699 | if (typeof test === 'function') |
| 1700 | if (test(obj ?? data, res) === false) |
| 1701 | throw "failed test: " + test |
| 1702 | return obj ?? data |
| 1703 | } |
| 1704 | } |
| 1705 | |
| 1706 | async function readEventStreamOnce(url: string, { baseUrl, ...requestOptions }: XRequestOptions & { baseUrl?: string }={}) { |
| 1707 | const res = await httpStream((baseUrl || defaultBaseUrl) + url, { |
| 1708 | path: url, |
| 1709 | httpThrow: false, |
| 1710 | headers: { accept: 'text/event-stream', ...requestOptions.headers }, |
| 1711 | ...requestOptions, |
| 1712 | }) |
| 1713 | const data = await new Promise<string>((resolve, reject) => { |
| 1714 | const timer = setTimeout(() => { |
| 1715 | res.destroy() |
| 1716 | reject(Error('event stream timeout')) |
| 1717 | }, 2000) |
| 1718 | res.once('data', chunk => { |
| 1719 | clearTimeout(timer) |
| 1720 | resolve(String(chunk)) |
| 1721 | res.destroy() |
| 1722 | }) |
| 1723 | res.once('end', () => { |
| 1724 | clearTimeout(timer) |
| 1725 | resolve('') |
| 1726 | }) |
| 1727 | res.once('error', err => { |
| 1728 | clearTimeout(timer) |
| 1729 | reject(err) |
| 1730 | }) |
| 1731 | }) |
| 1732 | return { status: res.statusCode, data } |
| 1733 | } |
| 1734 | |
| 1735 | function reqApi(api: string, params: object, test:Tester, options?: ReqOptions) { |
| 1736 | const isGet = api.startsWith('/') |
| 1737 | return req(API+api, test, { |
| 1738 | body: JSON.stringify(params), |
| 1739 | headers: isGet ? undefined : { 'x-hfs-anti-csrf': '1'}, |
| 1740 | ...options, |
| 1741 | }) |
| 1742 | } |
| 1743 | |
| 1744 | function reqList(uri:string, tester:Tester, params?: object, options?: ReqOptions) { |
| 1745 | return reqApi('get_file_list', { uri, ...params }, tester, options) |
| 1746 | } |
| 1747 | |
| 1748 | function isInList(res:any, name:string) { |
| 1749 | return Array.isArray(res?.list) && (res.list as any[]).some(x => x.n===name) |
| 1750 | } |
| 1751 | |
| 1752 | function noVisibleDetails(res: any) { |
| 1753 | return Array.isArray(res?.details) && res.details.length === 0 |
| 1754 | } |
| 1755 | |
| 1756 | function rmAny(path: string) { |
| 1757 | return path && rm(path, { recursive: true, force: true }).catch(() => {}) |
| 1758 | } |
| 1759 | |
| 1760 | function throwIf(msg: any) { |
| 1761 | if (msg) |
| 1762 | throw msg |
| 1763 | } |
| 1764 | |
| 1765 | async function ensureCantOverwriteDir() { |
| 1766 | const baseDir = resolve(__dirname, 'work', CANT_OVERWRITE_NAME) |
| 1767 | await mkdir(baseDir, { recursive: true }) |
| 1768 | return baseDir |
| 1769 | } |
| 1770 | |
| 1771 | class StringRepeaterStream extends Readable { |
| 1772 | constructor(private str: string, private n: number, readonly length=n*str.length) { |
| 1773 | super() |
| 1774 | } |
| 1775 | _read() { |
| 1776 | this.push(this.n-- > 0 ? this.str : null) |
| 1777 | } |
| 1778 | } |
| 1779 | |
| 1780 | function makeReadableThatTakes(ms: number) { |
| 1781 | return Object.assign(Readable.from(BIG_CONTENT).pipe(new ThrottledStream(new ThrottleGroup(BIG_CONTENT.length / ms))), |
| 1782 | { length: BIG_CONTENT.length }) |
| 1783 | } |
| 1784 | |
| 1785 | async function curlWithStatus(cmd: string) { |
| 1786 | const out = (await execP(`${cmd} -w "\\nSTATUS:%{http_code}"`)).trimEnd() |
| 1787 | const idx = out.lastIndexOf('\nSTATUS:') |
| 1788 | if (idx < 0) |
| 1789 | throw "missing status in curl output" |
| 1790 | return { status: Number(out.slice(idx + 8)), body: out.slice(0, idx) } |
| 1791 | } |
| 1792 | |
| 1793 | async function withPluginConfig(id: string, config: object, cb: () => Promise<void>) { |
| 1794 | const prev = await reqApi('get_plugin', { id }, res => res?.config && 'enabled' in res, { auth })() |
| 1795 | // force a deterministic plugin lifecycle to avoid races where set_plugin returns before plugin init is completed |
| 1796 | await reqApi('stop_plugin', { id }, 200, { auth })() |
| 1797 | await reqApi('set_plugin', { id, enabled: false, config }, 200, { auth })() |
| 1798 | await reqApi('start_plugin', { id }, 200, { auth })() |
| 1799 | try { |
| 1800 | await cb() |
| 1801 | } |
| 1802 | finally { |
| 1803 | await reqApi('stop_plugin', { id }, 200, { auth })() |
| 1804 | await reqApi('set_plugin', { id, enabled: false, config: prev.config }, 200, { auth })() |
| 1805 | if (prev.enabled) |
| 1806 | await reqApi('start_plugin', { id }, 200, { auth })() |
| 1807 | } |
| 1808 | } |
| 1809 | |
| 1810 | async function withCustomHtml(sections: Record<string, string>, cb: () => Promise<void>) { |
| 1811 | const prev = await reqApi('get_custom_html', {}, res => res?.sections, { auth, jar: {} })() |
| 1812 | await reqApi('set_custom_html', { sections: { ...prev.sections, ...sections } }, 200, { auth, jar: {} })() |
| 1813 | try { |
| 1814 | await cb() |
| 1815 | } |
| 1816 | finally { |
| 1817 | await reqApi('set_custom_html', { sections: prev.sections }, 200, { auth, jar: {} })() |
| 1818 | } |
| 1819 | } |
| 1820 | |
| 1821 | async function reqBasicAuth(url: string, credentials: string) { |
| 1822 | const authorization = 'Basic ' + Buffer.from(credentials).toString('base64') |
| 1823 | const response = await httpStream(defaultBaseUrl + url, { |
| 1824 | path: url, |
| 1825 | httpThrow: false, |
| 1826 | jar: {}, |
| 1827 | headers: { authorization }, |
| 1828 | }) |
| 1829 | await stream2string(response).catch(() => '') |
| 1830 | const rawDelay = response.headers?.['x-anti-brute-force'] |
| 1831 | const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay |
| 1832 | return { |
| 1833 | status: response.statusCode, |
| 1834 | delay: Number(delayValue) || 0, |
| 1835 | } |
| 1836 | } |
| 1837 | |
| 1838 | async function reqLoginSrp1(username: string) { |
| 1839 | const response = await httpStream(defaultBaseUrl + API + 'loginSrp1', { |
| 1840 | path: API + 'loginSrp1', |
| 1841 | method: 'POST', |
| 1842 | httpThrow: false, |
| 1843 | jar: {}, |
| 1844 | headers: { 'content-type': 'application/json', 'x-hfs-anti-csrf': '1' }, |
| 1845 | body: JSON.stringify({ username }), |
| 1846 | }) |
| 1847 | const data = tryJson(await stream2string(response).catch(() => '')) |
| 1848 | const rawDelay = response.headers?.['x-anti-brute-force'] |
| 1849 | const delayValue = Array.isArray(rawDelay) ? rawDelay[0] : rawDelay |
| 1850 | return { |
| 1851 | status: response.statusCode, |
| 1852 | delay: Number(delayValue) || 0, |
| 1853 | salt: data?.salt, |
| 1854 | } |
| 1855 | } |