main
ts 1,855 lines 90 KB
Raw
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 }