fix: respect overridden can_upload on nested paths

Massimo Melina committed Jan 15, 2026 at 19:06 UTC 2236135eb7395cb0bb8a1919fc4bd03213e1bdd5
9 files changed +254 -190
package-lock.json
+59 -112
@@ -18,10 +18,10 @@
18 "@gregoranders/csv": "^0.0.13",
19 "@rejetto/kvstorage": "^0.14.1",
20 "acme-client": "^5.4.0",
21 + "busboy": "^1.6.0",
22 "crc-32": "^1.2.2",
23 "fast-glob": "^3.3.3",
24 "find-process": "^2.0.0",
24 - "formidable": "^3.5.4",
25 "fs-x-attributes": "^1.0.2",
26 "fswin": "^3.24.829",
27 "iconv-lite": "^0.7.0",
@@ -50,7 +50,7 @@
50 },
51 "devDependencies": {
52 "@playwright/test": "^1.55.1",
53 - "@types/formidable": "^3.4.5",
53 + "@types/busboy": "^1.5.4",
54 "@types/koa": "^3.0.0",
55 "@types/koa__router": "^12.0.4",
56 "@types/koa-compress": "^4.0.6",
@@ -2982,18 +2982,6 @@
2982 "react-dom": "^17.0.0 || ^18.0.0"
2983 }
2984 },
2985 - "node_modules/@noble/hashes": {
2986 - "version": "1.8.0",
2987 - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
2988 - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
2989 - "license": "MIT",
2990 - "engines": {
2991 - "node": "^14.21.3 || >=16"
2992 - },
2993 - "funding": {
2994 - "url": "https://paulmillr.com/funding/"
2995 - }
2996 - },
2985 "node_modules/@nodelib/fs.scandir": {
2986 "version": "2.1.5",
2987 "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3026,15 +3014,6 @@
3014 "node": ">= 8"
3015 }
3016 },
3029 - "node_modules/@paralleldrive/cuid2": {
3030 - "version": "2.2.2",
3031 - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
3032 - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==",
3033 - "license": "MIT",
3034 - "dependencies": {
3035 - "@noble/hashes": "^1.1.5"
3036 - }
3037 - },
3017 "node_modules/@peculiar/asn1-cms": {
3018 "version": "2.3.8",
3019 "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
@@ -3520,6 +3499,16 @@
3499 "@types/node": "*"
3500 }
3501 },
3502 + "node_modules/@types/busboy": {
3503 + "version": "1.5.4",
3504 + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
3505 + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
3506 + "dev": true,
3507 + "license": "MIT",
3508 + "dependencies": {
3509 + "@types/node": "*"
3510 + }
3511 + },
3512 "node_modules/@types/connect": {
3513 "version": "3.4.35",
3514 "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -3577,16 +3566,6 @@
3566 "@types/range-parser": "*"
3567 }
3568 },
3580 - "node_modules/@types/formidable": {
3581 - "version": "3.4.5",
3582 - "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz",
3583 - "integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==",
3584 - "dev": true,
3585 - "license": "MIT",
3586 - "dependencies": {
3587 - "@types/node": "*"
3588 - }
3589 - },
3569 "node_modules/@types/http-assert": {
3570 "version": "1.5.3",
3571 "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
@@ -4052,11 +4031,6 @@
4031 "node": ">=0.10.0"
4032 }
4033 },
4055 - "node_modules/asap": {
4056 - "version": "2.0.6",
4057 - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
4058 - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
4059 - },
4034 "node_modules/asn1js": {
4035 "version": "3.0.5",
4036 "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
@@ -4465,6 +4439,17 @@
4439 "url": "https://github.com/sponsors/sindresorhus"
4440 }
4441 },
4442 + "node_modules/busboy": {
4443 + "version": "1.6.0",
4444 + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
4445 + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
4446 + "dependencies": {
4447 + "streamsearch": "^1.1.0"
4448 + },
4449 + "engines": {
4450 + "node": ">=10.16.0"
4451 + }
4452 + },
4453 "node_modules/bytes": {
4454 "version": "3.1.2",
4455 "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -5040,15 +5025,6 @@
5025 "node": ">=8"
5026 }
5027 },
5043 - "node_modules/dezalgo": {
5044 - "version": "1.0.4",
5045 - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
5046 - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
5047 - "dependencies": {
5048 - "asap": "^2.0.0",
5049 - "wrappy": "1"
5050 - }
5051 - },
5028 "node_modules/dom-helpers": {
5029 "version": "5.2.1",
5030 "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -5444,23 +5420,6 @@
5420 "node": ">= 6"
5421 }
5422 },
5447 - "node_modules/formidable": {
5448 - "version": "3.5.4",
5449 - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
5450 - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
5451 - "license": "MIT",
5452 - "dependencies": {
5453 - "@paralleldrive/cuid2": "^2.2.2",
5454 - "dezalgo": "^1.0.4",
5455 - "once": "^1.4.0"
5456 - },
5457 - "engines": {
5458 - "node": ">=14.0.0"
5459 - },
5460 - "funding": {
5461 - "url": "https://ko-fi.com/tunnckoCore/commissions"
5462 - }
5463 - },
5423 "node_modules/fresh": {
5424 "version": "0.5.2",
5425 "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -6926,6 +6885,7 @@
6885 "version": "1.4.0",
6886 "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
6887 "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
6888 + "dev": true,
6889 "dependencies": {
6890 "wrappy": "1"
6891 }
@@ -8191,6 +8151,14 @@
8151 "readable-stream": "^2.1.4"
8152 }
8153 },
8154 + "node_modules/streamsearch": {
8155 + "version": "1.1.0",
8156 + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
8157 + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
8158 + "engines": {
8159 + "node": ">=10.0.0"
8160 + }
8161 + },
8162 "node_modules/streamx": {
8163 "version": "2.23.0",
8164 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
@@ -9051,7 +9019,8 @@
9019 "node_modules/wrappy": {
9020 "version": "1.0.2",
9021 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
9054 - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
9022 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
9023 + "dev": true
9024 },
9025 "node_modules/wsl-utils": {
9026 "version": "0.1.0",
@@ -10902,11 +10871,6 @@
10871 "react-transition-group": "^4.4.5"
10872 }
10873 },
10905 - "@noble/hashes": {
10906 - "version": "1.8.0",
10907 - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
10908 - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
10909 - },
10874 "@nodelib/fs.scandir": {
10875 "version": "2.1.5",
10876 "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -10930,14 +10894,6 @@
10894 "fastq": "^1.6.0"
10895 }
10896 },
10933 - "@paralleldrive/cuid2": {
10934 - "version": "2.2.2",
10935 - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
10936 - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==",
10937 - "requires": {
10938 - "@noble/hashes": "^1.1.5"
10939 - }
10940 - },
10897 "@peculiar/asn1-cms": {
10898 "version": "2.3.8",
10899 "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz",
@@ -11263,6 +11219,15 @@
11219 "@types/node": "*"
11220 }
11221 },
11222 + "@types/busboy": {
11223 + "version": "1.5.4",
11224 + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
11225 + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
11226 + "dev": true,
11227 + "requires": {
11228 + "@types/node": "*"
11229 + }
11230 + },
11231 "@types/connect": {
11232 "version": "3.4.35",
11233 "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@@ -11319,15 +11284,6 @@
11284 "@types/range-parser": "*"
11285 }
11286 },
11322 - "@types/formidable": {
11323 - "version": "3.4.5",
11324 - "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz",
11325 - "integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==",
11326 - "dev": true,
11327 - "requires": {
11328 - "@types/node": "*"
11329 - }
11330 - },
11287 "@types/http-assert": {
11288 "version": "1.5.3",
11289 "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.3.tgz",
@@ -11711,11 +11667,6 @@
11667 "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
11668 "dev": true
11669 },
11714 - "asap": {
11715 - "version": "2.0.6",
11716 - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
11717 - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
11718 - },
11670 "asn1js": {
11671 "version": "3.0.5",
11672 "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
@@ -11980,6 +11931,14 @@
11931 "run-applescript": "^7.0.0"
11932 }
11933 },
11934 + "busboy": {
11935 + "version": "1.6.0",
11936 + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
11937 + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
11938 + "requires": {
11939 + "streamsearch": "^1.1.0"
11940 + }
11941 + },
11942 "bytes": {
11943 "version": "3.1.2",
11944 "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -12360,15 +12319,6 @@
12319 "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==",
12320 "dev": true
12321 },
12363 - "dezalgo": {
12364 - "version": "1.0.4",
12365 - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
12366 - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
12367 - "requires": {
12368 - "asap": "^2.0.0",
12369 - "wrappy": "1"
12370 - }
12371 - },
12322 "dom-helpers": {
12323 "version": "5.2.1",
12324 "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -12653,16 +12603,6 @@
12603 "mime-types": "^2.1.12"
12604 }
12605 },
12656 - "formidable": {
12657 - "version": "3.5.4",
12658 - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
12659 - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
12660 - "requires": {
12661 - "@paralleldrive/cuid2": "^2.2.2",
12662 - "dezalgo": "^1.0.4",
12663 - "once": "^1.4.0"
12664 - }
12665 - },
12606 "fresh": {
12607 "version": "0.5.2",
12608 "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -13683,6 +13623,7 @@
13623 "version": "1.4.0",
13624 "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
13625 "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
13626 + "dev": true,
13627 "requires": {
13628 "wrappy": "1"
13629 }
@@ -14568,6 +14509,11 @@
14509 "readable-stream": "^2.1.4"
14510 }
14511 },
14512 + "streamsearch": {
14513 + "version": "1.1.0",
14514 + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
14515 + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="
14516 + },
14517 "streamx": {
14518 "version": "2.23.0",
14519 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
@@ -15145,7 +15091,8 @@
15091 "wrappy": {
15092 "version": "1.0.2",
15093 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
15148 - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
15094 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
15095 + "dev": true
15096 },
15097 "wsl-utils": {
15098 "version": "0.1.0",
package.json
+3 -3
@@ -14,7 +14,7 @@
14 "start-frontend": "npm run start --workspace=frontend",
15 "start-admin": "npm run start --workspace=admin",
16 "build-all": "npm audit --omit=dev --audit-level=moderate && rm -rf dist && npm i && npm run build-server && npm run test-with-server && (npm run build-frontend & npm run build-admin) && echo COMPLETED",
17 - "build-server": "rm -rf dist/src dist/plugins && tsc && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node afterbuild.js",
17 + "build-server": "rm -rf dist/src dist/plugins && npm i && tsc && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node afterbuild.js",
18 "build-frontend": "npm run build --workspace=frontend",
19 "build-admin": "npm run build --workspace=admin",
20 "server-for-test": "node dist/src --cwd tests/work --config tests --debug",
@@ -75,10 +75,10 @@
75 "@gregoranders/csv": "^0.0.13",
76 "@rejetto/kvstorage": "^0.14.1",
77 "acme-client": "^5.4.0",
78 + "busboy": "^1.6.0",
79 "crc-32": "^1.2.2",
80 "fast-glob": "^3.3.3",
81 "find-process": "^2.0.0",
81 - "formidable": "^3.5.4",
82 "fs-x-attributes": "^1.0.2",
83 "fswin": "^3.24.829",
84 "iconv-lite": "^0.7.0",
@@ -104,7 +104,7 @@
104 },
105 "devDependencies": {
106 "@playwright/test": "^1.55.1",
107 - "@types/formidable": "^3.4.5",
107 + "@types/busboy": "^1.5.4",
108 "@types/koa": "^3.0.0",
109 "@types/koa__router": "^12.0.4",
110 "@types/koa-compress": "^4.0.6",
src/first.ts
+11 -5
@@ -21,11 +21,17 @@ onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT', 'SIGHUP'], signal
21 // keep calling cb in a sync fashion – returning a promise instead would break the code for argv.updating (update.ts)
22 export function onFirstEvent(emitter:EventEmitter, events: string[], cb: (...args:any[])=> void) {
23 let already = false
24 - for (const e of events)
25 - emitter.once(e, (...args) => {
24 + const cleanup = () => {
25 + events.forEach((e, i) => emitter.off(e, handlers[i]!))
26 + }
27 + const handlers = events.map(e => {
28 + const handler = (...args: any[]) => {
29 if (already) return
30 already = true
28 - cb(...args)
29 - })
31 + cleanup()
32 + cb(e, ...args)
33 + }
34 + emitter.on(e, handler)
35 + return handler
36 + })
37 }
31 -
src/multipartUpload.ts new
+77
@@ -0,0 +1,77 @@
1 +import Koa from 'koa'
2 +import Busboy from 'busboy'
3 +import { once } from 'events'
4 +import { hasPermission, urlToNode, VfsNode } from './vfs'
5 +import { dirname } from 'path'
6 +import { uploadWriter } from './upload'
7 +import { HTTP_BAD_REQUEST } from './cross-const'
8 +import { onFirstEvent } from './first'
9 +
10 +export async function handleMultipartUpload(ctx: Koa.Context, node: VfsNode) {
11 + if (ctx.request.type !== 'multipart/form-data')
12 + return ctx.status = HTTP_BAD_REQUEST
13 + ctx.state.uploads = []
14 + const locks: Promise<string>[] = []
15 + const fileJobs: Promise<any>[] = []
16 + const errors: string[] = []
17 + const bb = Busboy({ headers: ctx.req.headers, preservePath: true })
18 + bb.on('field', (name: string) => {
19 + if (name === 'upload')
20 + errors.push('empty filename')
21 + })
22 + bb.on('file', (_field, file, info) => {
23 + const fn = info.filename || ''
24 + if (!fn) {
25 + errors.push('empty filename')
26 + fileJobs.push(drainStream(file))
27 + return
28 + }
29 + ctx.state.uploadPath = decodeURI(ctx.path) + fn
30 + ctx.state.uploads!.push(fn)
31 + file.pause()
32 + fileJobs.push(handleFile(file, fn))
33 + })
34 + bb.on('error', (err: Error) => {
35 + console.warn("Couldn't parse POST requests:", String(err))
36 + ctx.status = HTTP_BAD_REQUEST
37 + })
38 + ctx.req.pipe(bb)
39 + await once(bb, 'finish')
40 + await Promise.all(fileJobs)
41 + if (!ctx.state.uploads?.length) {
42 + if (!errors.length)
43 + errors.push('no files')
44 + ctx.status = HTTP_BAD_REQUEST
45 + }
46 + const uris = await Promise.all(locks)
47 + ctx.body = errors.length ? { uris, errors } : { uris }
48 + return
49 +
50 + async function handleFile(file: NodeJS.ReadableStream, fn: string) {
51 + try { // it is still possible to allow upload in a folder and block in a subfolder, so check for it
52 + const ret = !await subfolderBlocksUpload(fn) && uploadWriter(node, ctx.path, fn, ctx)
53 + if (!ret)
54 + return drainStream(file)
55 + locks.push(ret.lockMiddleware)
56 + file.pipe(ret)
57 + file.resume()
58 + }
59 + catch (e) {
60 + console.warn("Couldn't handle uploaded file:", String(e))
61 + file.resume()
62 + }
63 + }
64 +
65 + function drainStream(stream: NodeJS.ReadableStream) {
66 + stream.resume()
67 + return new Promise(res => onFirstEvent(stream, ['end','close','error'], res))
68 + }
69 +
70 + async function subfolderBlocksUpload(fn: string) {
71 + const prefix = dirname(fn.replaceAll('\\', '/'))
72 + if (prefix === '.') // no subdir
73 + return false
74 + const subfolderNode = await urlToNode(prefix + '/', ctx, node, true) // final slash = explicitly a folder even if it doesn't exist on disk
75 + return subfolderNode && !hasPermission(subfolderNode, 'can_upload', ctx)
76 + }
77 +}
src/serveGuiAndSharedFiles.ts
+8 -32
@@ -4,13 +4,13 @@ import { getNodeName, nodeIsFolder, statusCodeForMissingPerm, urlToNode, vfs, Vf
4 import { sendErrorPage } from './errorPages'
5 import events from './events'
6 import {
7 - ADMIN_URI, FRONTEND_URI, HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
7 + ADMIN_URI, FRONTEND_URI, HTTP_FORBIDDEN, HTTP_METHOD_NOT_ALLOWED, HTTP_NOT_FOUND,
8 HTTP_UNAUTHORIZED, HTTP_SERVER_ERROR, HTTP_OK, ICONS_URI, HTTP_FAILED_DEPENDENCY, UPLOAD_TEMP_HASH
9 } from './cross-const'
10 import { getUploadTempFor, uploadWriter } from './upload'
11 -import formidable from 'formidable'
11 +import { handleMultipartUpload } from './multipartUpload'
12 import { once } from 'events'
13 -import { Transform, Writable } from 'stream'
13 +import { Transform } from 'stream'
14 import { serveFile, serveFileNode } from './serveFile'
15 import { BUILD_TIMESTAMP, DEV, MIME_AUTO, VERSION } from './const'
16 import { zipStreamFromFolder } from './zip'
@@ -60,9 +60,9 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
60 const getUploadTempHash = get === UPLOAD_TEMP_HASH
61 if (ctx.method === 'PUT' || getUploadTempHash) { // PUT is what you get with `curl -T file url/`
62 const decPath = decodeURIComponent(path)
63 - let rest = basename(decPath)
63 + const rest = basename(decPath)
64 const folderUri = pathEncode(dirname(decPath)) // re-encode to get readable urls
65 - const folder = await urlToNode(folderUri, ctx, vfs, v => rest = v+'/'+rest)
65 + const folder = await urlToNode(folderUri, ctx, vfs, true)
66 if (!folder)
67 return sendErrorPage(ctx, HTTP_NOT_FOUND)
68 ctx.state.uploadPath = decPath
@@ -89,32 +89,8 @@ export const serveGuiAndSharedFiles: Koa.Middleware = async (ctx, next) => {
89 let node = await urlToNode(path, ctx)
90 if (!node)
91 return sendErrorPage(ctx, HTTP_NOT_FOUND)
92 - if (ctx.method === 'POST') { // curl -F upload=@file url/
93 - if (ctx.request.type !== 'multipart/form-data')
94 - return ctx.status = HTTP_BAD_REQUEST
95 - ctx.state.uploads = []
96 - let locks: Promise<string>[] = []
97 - const form = formidable({
98 - maxFileSize: Infinity,
99 - allowEmptyFiles: true,
100 - fileWriteStreamHandler: f => {
101 - const fn = (f as any).originalFilename
102 - ctx.state.uploadPath = decodeURI(ctx.path) + fn
103 - ctx.state.uploads!.push(fn)
104 - const ret = uploadWriter(node!, path, fn, ctx)
105 - if (!ret)
106 - return new Writable({ write(data,enc,cb) { cb() } }) // just discard data
107 - locks.push(ret.lockMiddleware)
108 - return ret
109 - }
110 - })
111 - const uris = await new Promise<string[]>(res => form.parse(ctx.req, async err => {
112 - if (err) console.warn("Couldn't parse POST requests:", String(err)) // parsing failure is typically a client-side issue; log as a warning at most
113 - res(Promise.all(locks))
114 - }))
115 - ctx.body = { uris }
116 - return
117 - }
92 + if (ctx.method === 'POST') // curl -F upload=@file url/
93 + return handleMultipartUpload(ctx, node)
94 if (ctx.method === 'DELETE') {
95 const { source } = node
96 if (!source)
@@ -206,4 +182,4 @@ declare module "koa" {
182 uploadPath?: string // current one
183 uploads?: string[] // in case of request with potentially multiple uploads (POST), we register all filenames (no full path)
184 }
209 -}
\ No newline at end of file
185 +}
src/upload.ts
+8 -8
@@ -7,7 +7,7 @@ import {
7 import { basename, dirname, extname, join } from 'path'
8 import fs from 'fs'
9 import {
10 - hasDirTraversal, loadFileAttr, pendingPromise, storeFileAttr, try_, createStreamLimiter, pathEncode,
10 + isValidFileName, loadFileAttr, pendingPromise, storeFileAttr, try_, createStreamLimiter, pathEncode,
11 enforceFinal, Timeout,
12 } from './misc'
13 import { defineConfig } from './config'
@@ -58,13 +58,13 @@ export function getUploadTempFor(fullPath: string) {
58
59 const diskSpaceCache = expiringCache<ReturnType<typeof getDiskSpaceSync>>(3_000) // invalidate shortly
60 const uploadingFiles = new Map<string, { ctx: Koa.Context, size: number, got: number }>()
61 -// stay sync because we use this function with formidable()
62 -export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx: Koa.Context) {
63 - if (hasDirTraversal(path))
61 +// initially sync for formidable; still sync to avoid async races and PUT piping gaps
62 +export function uploadWriter(base: VfsNode, baseUri: string, filename: string, ctx: Koa.Context) {
63 + if (!filename || !isValidFileName(filename))
64 return fail(HTTP_FOOL)
65 if (statusCodeForMissingPerm(base, 'can_upload', ctx))
66 return fail()
67 - const fullPath = join(base.source!, path)
67 + const fullPath = join(base.source!, filename)
68 const already = uploadingFiles.get(fullPath) // this can be checked so early because this function is sync
69 if (already) // if it's the same client, we tell to retry later
70 return fail(HTTP_CONFLICT, ctx.query.id && ctx.query.id === already.ctx.query.id ? 'retry' : 'already uploading')
@@ -160,7 +160,7 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
160 const lockMiddleware = pendingPromise<string>() // expose outside, to let know when all operations stopped
161 let errored: any
162 fileStream.on('error', (e: any) => {
163 - console.warn('file error while uploading', path, ':', e.message)
163 + console.warn('file error while uploading', filename, ':', e.message)
164 errored = e
165 fail(HTTP_SERVER_ERROR, e.code) // don't send e.message as it may contain a disk paths we don't want to leak
166 })
@@ -247,7 +247,7 @@ export function uploadWriter(base: VfsNode, baseUri: string, path: string, ctx:
247
248 async function overwriteAnyway() {
249 if (ctx.query.existing !== 'overwrite') return false
250 - const n = await getNodeByName(path, base)
250 + const n = await getNodeByName(filename, base)
251 if (n && !statusCodeForMissingPerm(n, 'can_delete', ctx)) return true
252 overwriteRequestedButForbidden = true
253 return false
@@ -290,4 +290,4 @@ declare module "koa" {
290 uploadDestinationPath?: string
291 uploadSize?: number
292 }
293 -}
\ No newline at end of file
293 +}
src/vfs.ts
+20 -8
@@ -104,7 +104,12 @@ export async function applyParentToChild(child: VfsNode | undefined, parent: Vfs
104 return ret
105 }
106
107 -export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=vfs, getRest?: (rest: string) => any) : Promise<VfsNode | undefined> {
107 +export async function urlToNode(
108 + url: string,
109 + ctx?: Koa.Context,
110 + parent: VfsNode=vfs,
111 + resolveMissing?: true | ((rest: string) => any)
112 +) : Promise<VfsNode | undefined> {
113 let initialSlashes = 0
114 while (url[initialSlashes] === '/')
115 initialSlashes++
@@ -112,20 +117,25 @@ export async function urlToNode(url: string, ctx?: Koa.Context, parent: VfsNode=
117 const name = decodeURIComponent(url.slice(initialSlashes, nextSlash < 0 ? undefined : nextSlash))
118 if (!name)
119 return parent
115 - const rest = nextSlash < 0 ? '' : url.slice(nextSlash+1, url.endsWith('/') ? -1 : undefined)
116 - const ret = await getNodeByName(name, parent)
120 + const hasTrailingSlash = url.endsWith('/')
121 + const rest = nextSlash < 0 ? '' : url.slice(nextSlash+1, hasTrailingSlash ? -1 : undefined)
122 + const allowMissing = resolveMissing === true
123 + const assumeFolder = allowMissing && (rest > '' || hasTrailingSlash)
124 + const ret = await getNodeByName(name, parent, assumeFolder)
125 if (!ret)
126 return
127 if (rest || ret?.original)
120 - return urlToNode(rest, ctx, ret, getRest)
128 + return urlToNode(rest, ctx, ret, resolveMissing)
129 if (ret.source)
130 if (!showHiddenFiles.get() && await isHiddenFile(ret.source))
131 throw 'hiddenFile'
132 else if (await setIsFolder(ret) === undefined) { // undefined = not found on disk
125 - if (!getRest)
133 + if (!resolveMissing)
134 return
127 - const rest = ret.source.slice(parent.source!.length) // parent has source, otherwise !ret.source || ret.original
128 - getRest(removeStarting('/', rest))
135 + if (allowMissing)
136 + return ret
137 + const rest = ret.source!.slice(parent.source!.length) // we know parent has .source, otherwise !ret.source || ret.original
138 + resolveMissing(removeStarting('/', rest))
139 return parent
140 }
141 return ret
@@ -146,7 +156,7 @@ async function isHiddenFile(path: string) {
156 : path[path.lastIndexOf('/') + 1] === '.'
157 }
158
149 -export async function getNodeByName(name: string, parent: VfsNode) {
159 +export async function getNodeByName(name: string, parent: VfsNode, assumeMissingToBeFolder=false) {
160 // does the tree node have a child that goes by this name, otherwise attempt disk
161 const child = parent.children?.find(isSameFilenameAs(name)) || await childFromDisk()
162 return child && applyParentToChild(child, parent, name)
@@ -167,6 +177,8 @@ export async function getNodeByName(name: string, parent: VfsNode) {
177 ret.source = join(parent.source, onDisk)
178 ret.original = undefined // this will overwrite the 'original' set in applyParentToChild, so we know this is not part of the vfs
179 await setIsFolder(ret)
180 + if (assumeMissingToBeFolder)
181 + ret.isFolder ??= true
182 return ret
183 }
184 }
tests/config.yaml
+4
@@ -46,6 +46,10 @@ vfs:
46 - admins
47 can_delete:
48 - admins
49 + children:
50 + - name: no-upload
51 + source: ../tmp/no-upload
52 + can_upload: false
53 - source: ../alfa.txt
54 - name: for-disabled
55 can_list:
tests/test.ts
+64 -22
@@ -1,14 +1,14 @@
1 import test, { describe, before, after } from 'node:test';
2 import { promisify } from 'util'
3 import { srpClientSequence } from '../src/srp'
4 -import { createReadStream, statfsSync, statSync } from 'fs'
4 +import { createReadStream, existsSync, statfsSync, statSync } from 'fs'
5 import { basename, dirname, resolve } from 'path'
6 import { exec } from 'child_process'
7 import _ from 'lodash'
8 import { findDefined, randomId, try_, tryJson, wait } from '../src/cross'
9 import { httpStream, stream2string, XRequestOptions } from '../src/util-http'
10 import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream'
11 -import { access, mkdir, rm, writeFile } from 'fs/promises'
11 +import { mkdir, rm, writeFile } from 'fs/promises'
12 import { Readable } from 'stream'
13 /*
14 import { PORT, srv } from '../src'
@@ -20,6 +20,7 @@ const appStarted = new Promise(resolve =>
20
21 const username = 'rejetto'
22 const password = 'password'
23 +const auth = `${username}:${password}`
24 const API = '/~/api/'
25 const ROOT = 'tests/'
26 const BASE_URL = 'http://[::1]:81'
@@ -31,6 +32,7 @@ const BIG_CONTENT = _.repeat(randomId(10), 300_000) // 3MB, big enough to satura
32 const throttle = BIG_CONTENT.length /1000 /0.8 // KB, finish in 0.8s, quick but still overlapping downloads
33 const SAMPLE_FILE_PATH = resolve(__dirname, 'page/gpl.png')
34 let defaultBaseUrl = BASE_URL
35 +const execP = (cmd: string) => promisify(exec)(cmd).then(x => x.stdout)
36
37 class StringRepeaterStream extends Readable {
38 constructor(private str: string, private n: number, readonly length=n*str.length) {
@@ -160,15 +162,42 @@ describe('basics', () => {
162
163 test('upload.need account', reqUpload( UPLOAD_DEST, 401))
164 test('upload.post', () => // this is also testing basic-auth
163 - promisify(exec)(`curl -u ${username}:${password} -F upload=@${SAMPLE_FILE_PATH} ${BASE_URL}${UPLOAD_ROOT}`).then(x => {
164 - let fn = tryJson(x.stdout)?.uris?.[0]
165 - if (!fn) throw "unexpected output " + (x.stdout || x.stderr)
166 - fn = resolve(__dirname, basename(decodeURI(fn)))
165 + execP(`curl -u ${auth} -F upload=@${SAMPLE_FILE_PATH} ${BASE_URL}${UPLOAD_ROOT}`).then(x => {
166 + const uri = tryJson(x)?.uris?.[0]
167 + if (!uri) throw "unexpected output " + x
168 + const fn = resolve(__dirname, basename(decodeURI(uri)))
169 const stats = statSync(fn)
170 rm(fn).catch(() => {}) // clear
171 if (stats?.size !== statSync(SAMPLE_FILE_PATH).size)
172 throw "unexpected size for " + fn
173 }))
174 + test('upload.post.empty filename', () => {
175 + const boundary = '----hfs-boundary'
176 + 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`
177 + return execP(`printf '%b' "${body}" | curl -s -u ${auth} -H "Content-Type: multipart/form-data; boundary=${boundary}" --data-binary @- ${BASE_URL}${UPLOAD_ROOT} -w "\\nSTATUS:%{http_code}"`).then(x => {
178 + const out = x.trimEnd()
179 + const idx = out.lastIndexOf('\nSTATUS:')
180 + const status = out.slice(idx + 8)
181 + if (status !== '400')
182 + throw "unexpected status " + status
183 + const errMsg = tryJson(out.slice(0, idx))?.errors?.[0]
184 + if (!['empty filename', 'no files'].includes(errMsg))
185 + throw 'missing error'
186 + })
187 + })
188 + test('upload.post.absolute filename', () => {
189 + const absPath = resolve(ROOT, `abs-${randomId(6)}.txt`)
190 + const absForBody = absPath.replace(/\\\\/g, '/')
191 + const storedPath = resolve(ROOT, 'tmp', basename(absPath))
192 + return execP(`curl -s -u ${auth} -H "x-hfs-wait: 1" -F "upload=@${SAMPLE_FILE_PATH};filename=${absForBody}" ${BASE_URL}${UPLOAD_ROOT} -w "\\nSTATUS:%{http_code}"`).then(async x => {
193 + const out = x.trimEnd()
194 + const idx = out.lastIndexOf('\nSTATUS:')
195 + const status = out.slice(idx + 8)
196 + throwIf(status !== '418' ? "unexpected status " + status
197 + : existsSync(absPath) ? "absolute path accepted"
198 + : existsSync(storedPath) ? "stored file escaped" : '')
199 + }).finally(() => Promise.all([rmAny(absPath), rmAny(storedPath)]))
200 + })
201 test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 'temp' }, 401))
202 test('create_folder.bad type', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 123 }, { status: 400, re: /name/ }))
203 test('delete.no perm', req('/for-admins/', 405, { method: 'delete' }))
@@ -186,9 +215,9 @@ describe('basics', () => {
215 test('folder size.cant', reqApi('get_folder_size', { uri: 'for-admins' }, 401))
216
217 test('get_accounts', reqApi('get_accounts', {}, 401)) // admin api requires login
189 - test('url login', () => promisify(exec)(`curl -v "${BASE_URL}/for-admins/?login=${username}:${password}"`).then(x => {
190 - if (!x.stdout?.includes('Redirect'))
191 - throw x.stderr || "failed"
218 + test('url login', () => execP(`curl -s -D - -o /dev/null "${BASE_URL}/for-admins/?login=${auth}"`).then(x => {
219 + if (!/^(location|set-cookie):/im.test(x))
220 + throw "failed"
221 }))
222 })
223
@@ -218,15 +247,11 @@ describe('after-login', () => {
247 test('upload.ok', reqUpload(UPLOAD_DEST, 200))
248 test('file_details.admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => {
249 const u = res?.details?.[0]?.upload
221 - const err = !u?.ip ? 'ip' : u?.username !== username ? 'username' : ''
222 - if (err)
223 - throw err
250 + throwIf(!u?.ip ? 'ip' : u?.username !== username ? 'username' : '')
251 }))
252 test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => {
253 const u = res?.details?.[0]?.upload
227 - const err = !u ? 'missing upload' : u?.ip ? 'ip' : u?.username !== username ? 'username' : ''
228 - if (err)
229 - throw err
254 + throwIf(!u ? 'missing upload' : u?.ip ? 'ip' : u?.username !== username ? 'username' : '')
255 }, { jar: {} }))
256 test('upload but not delete', async () => {
257 const name = `cant-delete`
@@ -239,9 +264,16 @@ describe('after-login', () => {
264 }
265 finally {
266 await reqApi('del_vfs', { uris: [UPLOAD_ROOT + name] }, 200)().catch(() => {})
242 - await rm(resolve(ROOT, name), { recursive: true, force: true }).catch(() => {})
267 + await rmAny(resolve(ROOT, name))
268 }
269 })
270 + test('upload.path bypass', async () => {
271 + const name = 'no-upload'
272 + const targetDir = resolve(ROOT, 'tmp', name)
273 + await execP(`curl -g -s -u ${auth} -F "upload=@${SAMPLE_FILE_PATH};filename=${name}/evil.txt" ${BASE_URL}${UPLOAD_ROOT}`)
274 + if (existsSync(resolve(targetDir, 'evil.txt')))
275 + throw "file created"
276 + })
277 test('upload.existing.skip', async () => {
278 const filePath = resolve(__dirname, UPLOAD_RELATIVE)
279 const before = statSync(filePath).size
@@ -297,9 +329,11 @@ describe('after-login', () => {
329 test('delete.miss renamed', req(UPLOAD_DEST, 404, { method: 'delete' }))
330 test('delete.ok', async () => {
331 const fn = resolve(__dirname, dirname(UPLOAD_RELATIVE), renameTo)
300 - await access(fn)
332 + if (!existsSync(fn))
333 + throw "missing file"
334 await req(dirname(UPLOAD_DEST) + '/' + renameTo, 200, { method: 'delete' })()
302 - await access(fn).then(() => { throw "not deleted" }, () => {})
335 + if (existsSync(fn))
336 + throw "not deleted"
337 })
338 test('reupload', reqUpload(UPLOAD_DEST, 200))
339 test('delete.method', req(UPLOAD_DEST, 200, { method: 'DELETE' }))
@@ -334,11 +368,10 @@ describe('after-login', () => {
368 await reqApi('logout', {}, 401)()
369 await reqApi('get_accounts', {}, 401)() // no more
370 })
337 - after(() => rm(resolve(__dirname, 'temp'), { recursive: true }).catch(() => 0))
371 + after(() => rmAny(resolve(__dirname, 'temp')))
372 })
373
374 describe('admin', () => {
341 - const auth = `${username}:${password}`
375 test('add folder', async () => {
376 const name = 'added'
377 try {
@@ -378,7 +411,7 @@ function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?
411 const fn = ROOT + decodeURI(data.uri).replace(UPLOAD_ROOT, '')
412 const stats = try_(() => statSync(fn))
413 if (!stats)
381 - throw Error("uploaded file not found: " + fn)
414 + throw "uploaded file not found: " + fn
415 if (size !== stats.size)
416 throw `uploaded file wrong size: ${fn} = ${stats.size.toLocaleString()} expected ${size?.toLocaleString()}`
417 return true
@@ -478,7 +511,7 @@ function req(url: string, test:Tester, { baseUrl, throttle, ...requestOptions }:
511 }
512 if (typeof test === 'function')
513 if (test(obj ?? data, res) === false)
481 - throw Error("failed test: " + test)
514 + throw "failed test: " + test
515 return obj ?? data
516 }
517 }
@@ -499,3 +532,12 @@ function reqList(uri:string, tester:Tester, params?: object) {
532 function isInList(res:any, name:string) {
533 return Array.isArray(res?.list) && Boolean((res.list as any[]).find(x => x.n===name))
534 }
535 +
536 +function rmAny(path: string) {
537 + return rm(path, { recursive: true, force: true }).catch(() => {})
538 +}
539 +
540 +function throwIf(msg: any) {
541 + if (msg)
542 + throw msg
543 +}