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
+}