diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fa2ea8..1898e3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,28 @@ jobs: - name: Audit all dependencies run: npm audit --audit-level=moderate + + stress: + name: Stress and resilience scenarios + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Install dependencies + run: npm ci + + # Boots a throwaway server on a random port with a temp DATA_DIR, drives + # rate-limit / flood / slow-socket / sustained-load scenarios against it, + # and fails the job if any threshold is missed. See + # server/src/stress/README.md for what each scenario asserts. + - name: Run stress scenarios + run: npm run stress --workspace=server diff --git a/client/package.json b/client/package.json index 3374a08..35effc6 100644 --- a/client/package.json +++ b/client/package.json @@ -15,7 +15,7 @@ "lucide-react": "^0.378.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.4", + "react-router-dom": "^7.18.2", "recharts": "^2.12.7" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 6433e5c..414a28f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "lucide-react": "^0.378.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.4", + "react-router-dom": "^7.18.2", "recharts": "^2.12.7" }, "devDependencies": { @@ -368,40 +368,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -691,24 +657,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/netbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", @@ -726,24 +674,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/openbsd-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", @@ -761,24 +691,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", @@ -897,25 +809,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1034,29 +927,31 @@ "@otplib/core": "13.4.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } + "license": "MIT" }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=14.0.0" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -1065,15 +960,12 @@ "optional": true, "os": [ "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -1082,15 +974,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -1099,15 +988,26 @@ "optional": true, "os": [ "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -1116,15 +1016,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -1133,32 +1030,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -1167,379 +1058,105 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ - "s390x" + "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ - "x64" + "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ - "x64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "linux" + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ - "wasm32" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "os": [ + "linux" + ] }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "linux" + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.3", @@ -1720,17 +1337,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2169,16 +1775,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2186,27 +1792,54 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -2214,14 +1847,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2230,9 +1863,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -2240,13 +1873,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2462,13 +2095,13 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2596,9 +2229,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -2635,9 +2268,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4338,9 +3971,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4553,278 +4186,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5248,9 +4609,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -5713,9 +5074,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -5733,7 +5094,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6196,35 +5557,54 @@ } }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "react-router": "7.18.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/react-smooth": { @@ -6402,47 +5782,6 @@ "rimraf": "bin.js" } }, - "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.132.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -6647,6 +5986,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6660,9 +6005,9 @@ "license": "MIT" }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -7047,9 +6392,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.6", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.6.tgz", - "integrity": "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA==", + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", + "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", "license": "MIT", "os": [ "darwin", @@ -7065,7 +6410,7 @@ "systeminformation": "lib/cli.js" }, "engines": { - "node": ">=8.0.0" + "node": ">=10.0.0" }, "funding": { "type": "Buy me a coffee", @@ -7253,9 +6598,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -7711,19 +7056,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -7751,12 +7096,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -7800,495 +7145,10 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -8298,84 +7158,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -8554,7 +7336,7 @@ "@types/uuid": "^9.0.8", "ts-node-dev": "^2.0.0", "typescript": "^5.4.5", - "vitest": "^4.1.7" + "vitest": "^4.1.10" } } } diff --git a/server/.env.example b/server/.env.example index ec8f704..12d6d4a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -11,6 +11,18 @@ PORT=3001 NODE_ENV=development +# Interface to bind. Defaults to loopback: Apache reverse-proxies to +# 127.0.0.1:3001 and terminates TLS, and the security headers plus the +# X-Forwarded-For handling below all assume nothing else can reach this port. +# Only change this if you terminate somewhere other than the local Apache. +BIND_HOST=127.0.0.1 + +# Number of reverse proxies in front of the app, for client-IP resolution. +# 1 = the local Apache. Raise it only if you add another trusted proxy (a CDN, +# a load balancer); setting it too high lets clients forge their own IP and +# walk straight past the login rate limit. +TRUST_PROXY_HOPS=1 + # ── Auth ───────────────────────────────────────────────────── # Long random string — generate with: openssl rand -hex 64 JWT_SECRET=change-this-to-a-long-random-string-in-production @@ -18,6 +30,11 @@ JWT_SECRET=change-this-to-a-long-random-string-in-production # Fallback admin used when the admin_users table is empty. After the # first login through the panel you can rotate the password from # /admin-users instead of editing this hash. +# +# There is no built-in default password. With NODE_ENV=production the server +# refuses to start unless either a real admin exists in the database or this +# holds a genuine hash — leaving it unset or on the example value below is a +# startup error, not a warning. ADMIN_USER=admin # Generate hash: node -e "console.log(require('bcryptjs').hashSync('yourpassword', 12))" ADMIN_PASS_HASH=$2b$12$examplehashhere @@ -57,3 +74,14 @@ DB_HOST=127.0.0.1 DB_PORT=3306 DB_ROOT_USER=root DB_ROOT_PASS= + +# ── Limits ──────────────────────────────────────────────────── +# Concurrent web-terminal sessions. Each one forks a real PTY, and the +# WebSocket upgrade path never passes through the HTTP rate limiter. +MAX_TERMINAL_SESSIONS=10 + +# Roots a cPanel transfer archive may be imported from (colon separated). +TRANSFER_ARCHIVE_ROOTS=/root:/home:/var/backups + +# Root of per-account home directories, used for SSH key management. +HOME_ROOT=/home diff --git a/server/package.json b/server/package.json index 708d4d1..a0f30b8 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,8 @@ "dev": "ts-node-dev --respawn --transpile-only src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "vitest run src" + "test": "vitest run src", + "stress": "npm run build && node dist/stress/run.js" }, "dependencies": { "@types/better-sqlite3": "^7.6.13", @@ -50,6 +51,6 @@ "@types/uuid": "^9.0.8", "ts-node-dev": "^2.0.0", "typescript": "^5.4.5", - "vitest": "^4.1.7" + "vitest": "^4.1.10" } } diff --git a/server/src/index.ts b/server/src/index.ts index 7628b81..e6b8c4b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -89,8 +89,16 @@ if (process.env.NODE_ENV === 'production') { console.error('[SECURITY] Refusing to start: JWT_SECRET is missing or set to the example value. Set a strong random value in /etc/hostpanel.env.'); process.exit(1); } - if (!process.env.ADMIN_PASS_HASH || process.env.ADMIN_PASS_HASH === '$2b$12$examplehashhere') { - console.warn('[SECURITY] ADMIN_PASS_HASH is set to the example value. Change your admin password immediately.'); + const hasDbAdmin = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const dbMod = require('./db').default; + return !!(dbMod.prepare("SELECT 1 FROM admin_users WHERE password_hash IS NOT NULL AND password_hash != ''").get()); + } catch { return false; } + })(); + if (!hasDbAdmin && (!process.env.ADMIN_PASS_HASH || process.env.ADMIN_PASS_HASH === '$2b$12$examplehashhere')) { + console.error('[SECURITY] Refusing to start: no admin account exists and ADMIN_PASS_HASH is missing or set to the example value. Generate one with: node -e "console.log(require(\'bcryptjs\').hashSync(\'yourpassword\', 12))"'); + process.exit(1); } } @@ -118,8 +126,9 @@ function publicBranding(_req: express.Request, res: express.Response) { } // The Node process sits behind Apache on the loopback interface (see install.sh). -// Trust X-Forwarded-* only from loopback so express-rate-limit keys on the real client IP. -app.set('trust proxy', 'loopback'); +// Trust exactly one hop (Apache) so a client-supplied X-Forwarded-For can't +// displace the real peer address the rate limiter keys on. +app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS ?? 1)); app.use(cors({ origin: process.env.CLIENT_URL || 'http://localhost:5173', credentials: true })); app.get('/healthz', publicHealth); @@ -337,8 +346,10 @@ startSelfHealthWatchdog({ dispatch: (event, payload) => dispatchNotification(event as any, payload).catch(() => {}), }); -httpServer.listen(PORT, () => { - console.log(`HostPanel API running on port ${PORT}`); +// Loopback-only: Apache is the only thing that should reach this port. +const BIND_HOST = process.env.BIND_HOST || '127.0.0.1'; +httpServer.listen(Number(PORT), BIND_HOST, () => { + console.log(`HostPanel API running on ${BIND_HOST}:${PORT}`); }); export default app; diff --git a/server/src/routes/accounts.ts b/server/src/routes/accounts.ts index 5530a1b..76a650f 100644 --- a/server/src/routes/accounts.ts +++ b/server/src/routes/accounts.ts @@ -9,6 +9,7 @@ import bcrypt from 'bcryptjs'; import rateLimit from 'express-rate-limit'; import db from '../db'; import { AuthRequest } from '../middleware/auth'; +import { accountInScope, requestResellerScope, scopeClause } from '../utils/reseller-scope'; const router = Router(); @@ -45,16 +46,18 @@ const VHOST_DIR = process.env.VHOST_DIR || '/etc/httpd/conf.d'; const WEBROOT = process.env.WEBROOT || '/var/www'; // GET /api/accounts — list all accounts with plan + client info -router.get('/', (_req: AuthRequest, res: Response) => { +router.get('/', (req: AuthRequest, res: Response) => { try { + const scope = scopeClause(req); const rows = db.prepare(` SELECT a.*, p.name as plan_name, p.price as plan_price, p.disk_quota, c.name as client_name, c.email as client_email FROM accounts a LEFT JOIN plans p ON a.plan_id = p.id LEFT JOIN clients c ON a.client_id = c.id + WHERE 1=1${scope.sql} ORDER BY a.created_at DESC - `).all(); + `).all(...scope.params); res.json(rows); } catch (err: any) { res.status(500).json({ error: err.message }); @@ -65,13 +68,14 @@ router.get('/', (_req: AuthRequest, res: Response) => { // routes in declaration order, so with the previous ordering the string // "check-expiry" was being captured as :id and the handler 404'd looking up // account id="check-expiry". -router.get('/check-expiry', async (_req: AuthRequest, res: Response) => { +router.get('/check-expiry', async (req: AuthRequest, res: Response) => { const now = new Date().toISOString().split('T')[0]; + const scope = scopeClause(req, 'reseller_id'); const expired = db.prepare(` SELECT id, username, domain, expires_at, status FROM accounts - WHERE expires_at IS NOT NULL AND expires_at <= ? AND status != 'suspended' - `).all(now) as any[]; + WHERE expires_at IS NOT NULL AND expires_at <= ? AND status != 'suspended'${scope.sql} + `).all(now, ...scope.params) as any[]; const suspended: string[] = []; for (const acc of expired) { @@ -100,7 +104,7 @@ router.get('/:id', (req: AuthRequest, res: Response) => { LEFT JOIN clients c ON a.client_id = c.id WHERE a.id = ? `).get(req.params.id); - if (!row) return res.status(404).json({ error: 'Account not found' }); + if (!row || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); res.json(row); }); @@ -118,6 +122,16 @@ router.post('/', async (req: AuthRequest, res: Response) => { return res.status(400).json({ error: 'Password must be at least 8 characters' }); } + const creatorScope = requestResellerScope(req); + if (creatorScope !== null) { + const alloc = db.prepare('SELECT alloc_accounts FROM resellers WHERE id = ?').get(creatorScope) as { alloc_accounts: number } | undefined; + if (!alloc) return res.status(403).json({ error: 'Reseller profile not found' }); + const used = (db.prepare("SELECT COUNT(*) AS n FROM accounts WHERE reseller_id = ? AND status != 'terminated'").get(creatorScope) as { n: number }).n; + if (used >= alloc.alloc_accounts) { + return res.status(403).json({ error: `Account allocation exhausted (${used}/${alloc.alloc_accounts})` }); + } + } + const docRoot = path.join(WEBROOT, domain, 'public_html'); try { @@ -145,10 +159,12 @@ router.post('/', async (req: AuthRequest, res: Response) => { await runFile('systemctl', ['reload', 'httpd']).catch(() => ({ stdout: '', stderr: '' })); // Insert into DB + const ownerScope = requestResellerScope(req); + const assignedReseller = ownerScope ?? (req.body.reseller_id ? Number(req.body.reseller_id) : null); const result = db.prepare(` - INSERT INTO accounts (username, domain, client_id, plan_id, notes, expires_at) - VALUES (?, ?, ?, ?, ?, ?) - `).run(username, domain, client_id || null, plan_id || null, notes || '', expires_at || null); + INSERT INTO accounts (username, domain, client_id, plan_id, notes, expires_at, reseller_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(username, domain, client_id || null, plan_id || null, notes || '', expires_at || null, assignedReseller); // Auto-generate invoice if plan exists if (plan_id && client_id) { @@ -180,6 +196,7 @@ router.patch('/:id/status', (req: AuthRequest, res: Response) => { if (!['active', 'suspended', 'terminated'].includes(status)) { return res.status(400).json({ error: 'status must be active, suspended, or terminated' }); } + if (!accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); try { db.prepare('UPDATE accounts SET status = ? WHERE id = ?').run(status, req.params.id); res.json({ success: true }); @@ -191,6 +208,7 @@ router.patch('/:id/status', (req: AuthRequest, res: Response) => { // PATCH /api/accounts/:id — update plan, client, notes, expires_at router.patch('/:id', (req: AuthRequest, res: Response) => { const { plan_id, client_id, notes, expires_at } = req.body; + if (!accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); try { db.prepare(` UPDATE accounts SET plan_id = ?, client_id = ?, notes = ?, expires_at = ? @@ -206,7 +224,7 @@ router.patch('/:id', (req: AuthRequest, res: Response) => { // DELETE /api/accounts/:id router.delete('/:id', async (req: AuthRequest, res: Response) => { const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(req.params.id) as any; - if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); try { // Remove vhost config (don't delete web files — admin should do that manually) await fs.unlink(path.join(VHOST_DIR, `${account.domain}.conf`)).catch(() => {}); @@ -221,7 +239,7 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => { // GET /api/accounts/:id/usage — disk usage for account's webroot dir router.get('/:id/usage', heavyLimit, async (req: AuthRequest, res: Response) => { const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(req.params.id) as any; - if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); // Account web roots are keyed on domain (see the POST /accounts handler: // docRoot = path.join(WEBROOT, domain, 'public_html')). The previous fallback @@ -272,7 +290,7 @@ router.get('/:id/usage', heavyLimit, async (req: AuthRequest, res: Response) => router.post('/:id/export', heavyLimit, async (req: AuthRequest, res: Response) => { const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(req.params.id) as any; - if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); // Defensive re-validation — usernames pass /^[a-zA-Z][a-zA-Z0-9_]{1,31}$/ at // creation, but a row could have been inserted via another path. Refuse to @@ -341,7 +359,7 @@ router.post('/:id/export', heavyLimit, async (req: AuthRequest, res: Response) = router.post('/:id/suspend', async (req: AuthRequest, res: Response) => { const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(req.params.id) as any; - if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); try { db.prepare("UPDATE accounts SET status='suspended' WHERE id=?").run(account.id); await fs.rename(path.join(VHOST_DIR, `${account.domain}.conf`), path.join(VHOST_DIR, `${account.domain}.conf.disabled`)).catch(() => {}); @@ -352,7 +370,7 @@ router.post('/:id/suspend', async (req: AuthRequest, res: Response) => { router.post('/:id/unsuspend', async (req: AuthRequest, res: Response) => { const account = db.prepare('SELECT * FROM accounts WHERE id = ?').get(req.params.id) as any; - if (!account) return res.status(404).json({ error: 'Account not found' }); + if (!account || !accountInScope(req, req.params.id)) return res.status(404).json({ error: 'Account not found' }); try { db.prepare("UPDATE accounts SET status='active' WHERE id=?").run(account.id); await fs.rename(path.join(VHOST_DIR, `${account.domain}.conf.disabled`), path.join(VHOST_DIR, `${account.domain}.conf`)).catch(() => {}); diff --git a/server/src/routes/apps.ts b/server/src/routes/apps.ts index 62caef9..6c57956 100644 --- a/server/src/routes/apps.ts +++ b/server/src/routes/apps.ts @@ -14,8 +14,17 @@ import { createBackgroundJob } from '../background-jobs'; // pm2 ENOENT" in the response. Pre-check pm2's presence and return a clean // 503 instead. `pm2 jlist` in the read path stays as-is because it's // wrapped in a try/catch that returns [] when pm2 is missing. +function pm2Path(): string | null { + const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + for (const dir of dirs) { + const candidate = path.join(dir, 'pm2'); + if (existsSync(candidate)) return candidate; + } + return null; +} + function pm2NotInstalled(res: Response): boolean { - if (existsSync('/usr/local/bin/pm2') || existsSync('/usr/bin/pm2')) return false; + if (pm2Path()) return false; res.status(503).json({ error: 'pm2 is not installed on this server. Install it with `npm install -g pm2` to use this feature.' }); return true; } diff --git a/server/src/routes/auth-hardening.integration.test.ts b/server/src/routes/auth-hardening.integration.test.ts new file mode 100644 index 0000000..9fa3623 --- /dev/null +++ b/server/src/routes/auth-hardening.integration.test.ts @@ -0,0 +1,115 @@ +/** + * Authentication hardening regressions: the env-credential fallback, the + * event-loop cost of the login path, and terminal WebSocket limits. + */ +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import bcrypt from 'bcryptjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +function listen(app: express.Express): Promise<{ url: string; close: () => Promise }> { + return new Promise(resolve => { + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('Missing address'); + resolve({ url: `http://127.0.0.1:${addr.port}`, close: () => new Promise((res, rej) => server.close(err => err ? rej(err) : res())) }); + }); + }); +} + +describe('admin login hardening', () => { + let tmp = ''; + let closeServer: (() => Promise) | undefined; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hostpanel-auth-')); + process.env.DATA_DIR = tmp; + process.env.JWT_SECRET = 'test-auth-secret'; + delete process.env.ADMIN_PASS_HASH; + delete process.env.ADMIN_USER; + vi.resetModules(); + }); + + afterEach(async () => { + if (closeServer) await closeServer(); + closeServer = undefined; + await fs.rm(tmp, { recursive: true, force: true }); + delete process.env.ADMIN_PASS_HASH; + vi.resetModules(); + }); + + async function authApp() { + const authRoutes = (await import('./auth')).default; + const app = express(); app.use(express.json()); app.use('/api/auth', authRoutes); + const server = await listen(app); closeServer = server.close; + return server.url; + } + + function login(url: string, username: string, password: string) { + return fetch(`${url}/api/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + } + + it('has no built-in default password when ADMIN_PASS_HASH is unset', async () => { + const url = await authApp(); + for (const pw of ['changeme', 'admin', 'password']) { + const res = await login(url, 'admin', pw); + expect(res.status, `password "${pw}" was accepted`).toBe(401); + } + // Empty password is rejected earlier, as a bad request. + expect((await login(url, 'admin', '')).status).toBe(400); + }); + + it('still accepts a correctly configured env admin', async () => { + process.env.ADMIN_USER = 'operator'; + process.env.ADMIN_PASS_HASH = await bcrypt.hash('a-real-password', 4); + const url = await authApp(); + expect((await login(url, 'operator', 'wrong')).status).toBe(401); + const ok = await login(url, 'operator', 'a-real-password'); + expect(ok.status).toBe(200); + expect((await ok.json()).token).toBeTruthy(); + }); + + it('does not block the event loop while rejecting logins', async () => { + const url = await authApp(); + // A synchronous hash on the reject path would stall the single Node + // thread; check the loop keeps ticking during a burst. + let ticks = 0; + const timer = setInterval(() => { ticks++; }, 5); + await Promise.all(Array.from({ length: 25 }, () => login(url, 'admin', 'wrong-password'))); + clearInterval(timer); + expect(ticks).toBeGreaterThan(3); + }); +}); + +describe('terminal websocket limits', () => { + it('only ever spawns an allowlisted shell', async () => { + const { selectTerminalShell } = await import('../terminal'); + expect(selectTerminalShell('/bin/bash')).toBe('/bin/bash'); + expect(selectTerminalShell('/bin/sh')).toBe('/bin/sh'); + expect(selectTerminalShell('/tmp/evil')).toBe('/bin/bash'); + expect(selectTerminalShell('/usr/bin/env python')).toBe('/bin/bash'); + expect(selectTerminalShell(undefined)).toBe('/bin/bash'); + }); + + it('strips panel secrets from the shell environment', async () => { + const { sanitizeTerminalEnv } = await import('../terminal'); + const env = sanitizeTerminalEnv({ + PATH: '/usr/bin', + JWT_SECRET: 'super-secret', + STRIPE_SECRET_KEY: 'sk_live_x', + CLOUDFLARE_API_TOKEN: 'cf', + SOME_PASSWORD: 'p', + DB_ROOT_PASS: 'r', + HOME: '/root', + }); + expect(env.PATH).toBe('/usr/bin'); + expect(env.HOME).toBe('/root'); + for (const leaked of ['JWT_SECRET', 'STRIPE_SECRET_KEY', 'CLOUDFLARE_API_TOKEN', 'SOME_PASSWORD', 'DB_ROOT_PASS']) { + expect(env[leaked], `${leaked} reached the shell`).toBeUndefined(); + } + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 1779fed..0f6d311 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -7,6 +7,10 @@ import db from '../db'; const router = Router(); +// Valid-shaped hash that nothing matches, so the miss path costs about the +// same as a real comparison. +const DUMMY_ADMIN_HASH = '$2a$12$0000000000000000000000000000000000000000000000000000'; + function jwtSecret() { return process.env.JWT_SECRET || 'hostpanel-secret-change-in-production'; } @@ -36,10 +40,13 @@ router.post('/login', async (req: Request, res: Response) => { totpSecret = dbUser.totp_secret; role = dbUser.role; } else { - // Fallback to env credentials + // Fallback to env credentials. With no hash configured, env login is closed. const ADMIN_USER = process.env.ADMIN_USER || 'admin'; - const ADMIN_PASS_HASH = process.env.ADMIN_PASS_HASH || bcrypt.hashSync('changeme', 10); - if (username !== ADMIN_USER) return res.status(401).json({ error: 'Invalid credentials' }); + const ADMIN_PASS_HASH = process.env.ADMIN_PASS_HASH; + if (!ADMIN_PASS_HASH || username !== ADMIN_USER) { + await bcrypt.compare(password, DUMMY_ADMIN_HASH); + return res.status(401).json({ error: 'Invalid credentials' }); + } valid = await bcrypt.compare(password, ADMIN_PASS_HASH); if (dbUser) { // Env user has set up 2FA — pull TOTP state from the DB row. @@ -83,7 +90,8 @@ router.post('/change-password', async (req: Request, res: Response) => { const pwError = validatePassword(newPassword); if (pwError) return res.status(400).json({ error: pwError }); - const ADMIN_PASS_HASH = process.env.ADMIN_PASS_HASH || bcrypt.hashSync('changeme', 10); + const ADMIN_PASS_HASH = process.env.ADMIN_PASS_HASH; + if (!ADMIN_PASS_HASH) return res.status(409).json({ error: 'No env admin password is configured; change this admin\'s password from /admin-users instead.' }); const valid = await bcrypt.compare(currentPassword, ADMIN_PASS_HASH); if (!valid) return res.status(401).json({ error: 'Current password is incorrect' }); diff --git a/server/src/routes/client-portal.ts b/server/src/routes/client-portal.ts index 00595a1..e938e11 100644 --- a/server/src/routes/client-portal.ts +++ b/server/src/routes/client-portal.ts @@ -9,6 +9,7 @@ import path from 'path'; import mysql from 'mysql2/promise'; import db from '../db'; import { requireClientFeature } from './feature-lists'; +import { assertSafeFileTarget } from '../utils/file-path'; import { registerDkimKey } from '../utils/opendkim'; import { createBackgroundJob } from '../background-jobs'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -55,15 +56,28 @@ function clientAccountUsernames(clientId: number, accountId: number | null = nul return rows.map(r => r.username).filter(u => /^[a-zA-Z][a-zA-Z0-9_]{1,31}$/.test(u)); } -// Returns the account-username if `name` is prefixed with one of the -// client's hosting account usernames followed by an underscore, otherwise -// null. Used to scope DB / FTP namespace. +// Returns the account-username that owns `name`, or null if the caller does +// not own it. Used to scope the DB / FTP / OS-user namespace. +// +// Account usernames may contain underscores, so `alice` and `alice_shop` can +// belong to different tenants. Resolve against every account and let the +// longest matching username win, then check the caller owns it. function clientPrefixOwner(clientId: number, name: string, accountId: number | null = null): string | null { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name)) return null; - for (const u of clientAccountUsernames(clientId, accountId)) { - if (name === u || name.startsWith(u + '_')) return u; + const all = db.prepare("SELECT id, username, client_id FROM accounts WHERE status != 'terminated'") + .all() as { id: number; username: string; client_id: number | null }[]; + + let owner: { id: number; username: string; client_id: number | null } | null = null; + for (const acct of all) { + if (!/^[a-zA-Z][a-zA-Z0-9_]{1,31}$/.test(acct.username)) continue; + if (name !== acct.username && !name.startsWith(acct.username + '_')) continue; + if (!owner || acct.username.length > owner.username.length) owner = acct; } - return null; + + if (!owner) return null; + if (Number(owner.client_id) !== Number(clientId)) return null; + if (accountId !== null && Number(owner.id) !== Number(accountId)) return null; + return owner.username; } function teamAccountScope(req: Request): number | null { @@ -103,6 +117,8 @@ const TEAM_PERMISSION_RULES: { re: RegExp; permission: string }[] = [ { re: /^\/errpages(?:\/|$)/, permission: 'files' }, { re: /^\/htpasswd(?:\/|$)/, permission: 'files' }, { re: /^\/hotlink(?:\/|$)/, permission: 'files' }, + { re: /^\/htaccess(?:\/|$)/, permission: 'files' }, + { re: /^\/security-scan(?:\/|$)/, permission: 'files' }, { re: /^\/ssl(?:\/|$)/, permission: 'files' }, { re: /^\/whois(?:\/|$)/, permission: 'dns' }, ]; @@ -113,30 +129,44 @@ function requiredTeamPermission(req: Request): string | null { return TEAM_PERMISSION_RULES.find(rule => rule.re.test(req.path))?.permission || '__unmapped_portal_route__'; } +// Each portal route maps to the feature-catalog key an admin toggles in the +// feature list. Keep these one-to-one — collapsing several routes onto one key +// makes the other toggles inert. const PORTAL_FEATURE_RULES: { re: RegExp; feature: string }[] = [ { re: /^\/email(?:\/|$)/, feature: 'email-accounts' }, { re: /^\/webmail(?:\/|$)/, feature: 'email-accounts' }, - { re: /^\/mail-auth(?:\/|$)/, feature: 'email-accounts' }, - { re: /^\/spam-rules(?:\/|$)/, feature: 'email-accounts' }, + { re: /^\/mail-auth(?:\/|$)/, feature: 'dkim' }, + { re: /^\/spam-rules(?:\/|$)/, feature: 'rspamd' }, { re: /^\/databases(?:\/|$)/, feature: 'databases' }, { re: /^\/phpmyadmin(?:\/|$)/, feature: 'phpmyadmin' }, { re: /^\/backups(?:\/|$)/, feature: 'backup-wizard' }, { re: /^\/stats(?:\/|$)/, feature: 'analytics' }, { re: /^\/files(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/sshkeys(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/cron(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/scripts(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/subdomains(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/redirects(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/errpages(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/htpasswd(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/hotlink(?:\/|$)/, feature: 'file-manager' }, - { re: /^\/ssl(?:\/|$)/, feature: 'file-manager' }, + { re: /^\/htaccess(?:\/|$)/, feature: 'file-manager' }, + { re: /^\/hotlink(?:\/|$)/, feature: 'web-extras' }, + { re: /^\/sshkeys(?:\/|$)/, feature: 'ssh-keys' }, + { re: /^\/cron(?:\/|$)/, feature: 'cron' }, + { re: /^\/scripts(?:\/|$)/, feature: 'scripts' }, + { re: /^\/subdomains(?:\/|$)/, feature: 'subdomains' }, + { re: /^\/redirects(?:\/|$)/, feature: 'redirects' }, + { re: /^\/errpages(?:\/|$)/, feature: 'error-pages' }, + { re: /^\/htpasswd(?:\/|$)/, feature: 'htpasswd' }, + { re: /^\/ssl(?:\/|$)/, feature: 'ssl-advanced' }, + { re: /^\/ftp(?:\/|$)/, feature: 'ftp' }, + { re: /^\/security-scan(?:\/|$)/, feature: 'security-scanner' }, + { re: /^\/domains\/[^/]+\/dns(?:\/|$)/, feature: 'domains' }, + { re: /^\/whois(?:\/|$)/, feature: 'domains' }, + { re: /^\/invoices(?:\/|$)/, feature: 'billing' }, ]; +// Reachable regardless of plan: session management and the account list. +const PORTAL_FEATURE_EXEMPT = /^\/(login|me|accounts|totp|change-password)(?:\/|$)/; + function requiredPortalFeature(req: Request): string | null { - if (/^\/(login|me|accounts|totp|change-password)(?:\/|$)/.test(req.path)) return null; - return PORTAL_FEATURE_RULES.find(rule => rule.re.test(req.path))?.feature || null; + if (PORTAL_FEATURE_EXEMPT.test(req.path)) return null; + const matched = PORTAL_FEATURE_RULES.find(rule => rule.re.test(req.path)); + // Fail closed so a newly added route is never ungated by default. + return matched ? matched.feature : '__unmapped_portal_route__'; } function clientAuth(req: Request, res: Response, next: NextFunction) { @@ -173,7 +203,23 @@ function clientAuth(req: Request, res: Response, next: NextFunction) { }; } const feature = requiredPortalFeature(req); - if (feature) return requireClientFeature(feature)(req, res, next); + if (feature === '__unmapped_portal_route__') { + return res.status(403).json({ error: 'Route is not mapped to a portal feature' }); + } + if (feature) { + // Routes keyed by an account-namespaced name rather than a domain still + // need to resolve to one account, or the check falls back to "any of this + // client's accounts has it" and leaks features across plans. + const named = req.body?.user || req.body?.dbName || req.body?.dbUser || req.body?.username || req.params?.user; + if (typeof named === 'string' && named) { + const owner = clientPrefixOwner((req as any).clientId, named, (req as any).teamAccountId || null); + if (owner) { + const acct = db.prepare('SELECT id FROM accounts WHERE username = ?').get(owner) as { id: number } | undefined; + if (acct) (req as any).portalAccountHint = acct.id; + } + } + return requireClientFeature(feature)(req, res, next); + } next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); @@ -1199,8 +1245,32 @@ router.post('/mail-auth/:domain/dmarc', clientAuth, async (req: Request, res: Re /* ── SSH keys (per-account-username OS user) ────────────────── */ const PORTAL_ACCT_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,31}$/; +const PORTAL_HOME_ROOT = process.env.HOME_ROOT || '/home'; + +// The tenant owns their home directory and the panel runs as root, so +// `ln -s /root/.ssh ~/.ssh` would redirect this write. lstat every component +// and refuse anything that isn't a real directory / regular file. +async function portalSshPaths(user: string): Promise<{ sshDir: string; authKeys: string }> { + if (!PORTAL_ACCT_RE.test(user)) throw new Error('Invalid user'); + const home = path.join(PORTAL_HOME_ROOT, user); + const homeStat = await fs.lstat(home).catch(() => null); + if (!homeStat) throw new Error(`No home directory for '${user}'`); + if (homeStat.isSymbolicLink() || !homeStat.isDirectory()) throw new Error('Home directory is not a real directory'); + + const sshDir = path.join(home, '.ssh'); + const sshStat = await fs.lstat(sshDir).catch(() => null); + if (sshStat && (sshStat.isSymbolicLink() || !sshStat.isDirectory())) throw new Error('.ssh is not a real directory'); + + const authKeys = path.join(sshDir, 'authorized_keys'); + const keyStat = await fs.lstat(authKeys).catch(() => null); + if (keyStat && (keyStat.isSymbolicLink() || !keyStat.isFile())) throw new Error('authorized_keys is not a regular file'); + + return { sshDir, authKeys }; +} + async function readPortalAccountKeys(username: string): Promise<{ id: number; raw: string; comment: string }[]> { - const content = await fs.readFile(path.join('/home', username, '.ssh', 'authorized_keys'), 'utf-8').catch(() => ''); + const { authKeys } = await portalSshPaths(username).catch(() => ({ authKeys: '' })); + const content = authKeys ? await fs.readFile(authKeys, 'utf-8').catch(() => '') : ''; return content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).map((raw, id) => { const parts = raw.trim().split(/\s+/); return { id, raw: raw.trim(), comment: parts[2] || '' }; @@ -1225,13 +1295,16 @@ router.post('/sshkeys', clientAuth, async (req: Request, res: Response) => { const trimmed = String(key).trim(); const valid = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521']; if (!valid.includes(trimmed.split(/\s+/)[0])) return res.status(400).json({ error: 'Invalid SSH key type' }); - const sshDir = path.join('/home', user, '.ssh'); - await fs.mkdir(sshDir, { recursive: true, mode: 0o700 }); - const file = path.join(sshDir, 'authorized_keys'); - const existing = await fs.readFile(file, 'utf-8').catch(() => ''); - await fs.writeFile(file, existing.replace(/\n+$/, '') + (existing ? '\n' : '') + trimmed + '\n', { mode: 0o600 }); - await runFile('chown', ['-R', `${user}:${user}`, sshDir]).catch(() => ({ stdout: '', stderr: '' })); - res.json({ success: true }); + // One line only — a newline appends extra authorized_keys entries. + if (/[\r\n]/.test(trimmed)) return res.status(400).json({ error: 'SSH key must be a single line' }); + try { + const { sshDir, authKeys } = await portalSshPaths(user); + await fs.mkdir(sshDir, { recursive: true, mode: 0o700 }); + const existing = await fs.readFile(authKeys, 'utf-8').catch(() => ''); + await fs.writeFile(authKeys, existing.replace(/\n+$/, '') + (existing ? '\n' : '') + trimmed + '\n', { mode: 0o600 }); + await runFile('chown', ['-R', `${user}:${user}`, sshDir]).catch(() => ({ stdout: '', stderr: '' })); + res.json({ success: true }); + } catch (e: any) { res.status(400).json({ error: e?.message || 'Could not write SSH key' }); } }); router.delete('/sshkeys/:user/:id', clientAuth, async (req: Request, res: Response) => { @@ -1241,10 +1314,12 @@ router.delete('/sshkeys/:user/:id', clientAuth, async (req: Request, res: Respon const keys = await readPortalAccountKeys(user); if (id < 0 || id >= keys.length) return res.status(404).json({ error: 'Key not found' }); keys.splice(id, 1); - const sshDir = path.join('/home', user, '.ssh'); - await fs.writeFile(path.join(sshDir, 'authorized_keys'), keys.map(k => k.raw).join('\n') + (keys.length ? '\n' : ''), { mode: 0o600 }); - await runFile('chown', ['-R', `${user}:${user}`, sshDir]).catch(() => ({ stdout: '', stderr: '' })); - res.json({ success: true }); + try { + const { sshDir, authKeys } = await portalSshPaths(user); + await fs.writeFile(authKeys, keys.map(k => k.raw).join('\n') + (keys.length ? '\n' : ''), { mode: 0o600 }); + await runFile('chown', ['-R', `${user}:${user}`, sshDir]).catch(() => ({ stdout: '', stderr: '' })); + res.json({ success: true }); + } catch (e: any) { res.status(400).json({ error: e?.message || 'Could not update SSH keys' }); } }); /* ── Backups (scoped to owned domain dirs) ──────────────────── */ @@ -1406,17 +1481,20 @@ router.post('/errpages/:domain/:code', clientAuth, async (req: Request, res: Res if (!/^(400|401|403|404|500|502|503)$/.test(code)) return res.status(400).json({ error: 'Unsupported code' }); const { content } = req.body; if (typeof content !== 'string') return res.status(400).json({ error: 'content required' }); - const dir = path.join(PORTAL_WEBROOT, domain, 'public_html', PORTAL_ERR_DIR); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, `${code}.html`), content); - // Wire ErrorDocument in .htaccess - const htaccess = path.join(PORTAL_WEBROOT, domain, 'public_html', '.htaccess'); - const directive = `ErrorDocument ${code} /${PORTAL_ERR_DIR}/${code}.html`; - const existing = await fs.readFile(htaccess, 'utf-8').catch(() => ''); - if (!existing.includes(directive)) { - await fs.writeFile(htaccess, existing.replace(/\n+$/, '') + (existing ? '\n' : '') + directive + '\n'); - } - res.json({ success: true }); + try { + const dir = await portalResolveOwnedPath((req as any).clientId, domain, `public_html/${PORTAL_ERR_DIR}`, teamAccountScope(req)); + await fs.mkdir(dir, { recursive: true }); + const page = await portalResolveOwnedPath((req as any).clientId, domain, `public_html/${PORTAL_ERR_DIR}/${code}.html`, teamAccountScope(req)); + await fs.writeFile(page, content); + // Wire ErrorDocument in .htaccess + const htaccess = await portalResolveOwnedPath((req as any).clientId, domain, 'public_html/.htaccess', teamAccountScope(req)); + const directive = `ErrorDocument ${code} /${PORTAL_ERR_DIR}/${code}.html`; + const existing = await fs.readFile(htaccess, 'utf-8').catch(() => ''); + if (!existing.includes(directive)) { + await fs.writeFile(htaccess, existing.replace(/\n+$/, '') + (existing ? '\n' : '') + directive + '\n'); + } + res.json({ success: true }); + } catch (e: any) { portalFileError(res, e); } }); /* ── File manager (scoped strictly to /var/www/) ── */ @@ -1428,36 +1506,47 @@ const portalFileUpload = multer({ const clientId = (req as any).clientId; const targetDomain = (req as any).portalTargetDomain; const targetSub = (req.query.subpath as string) || ''; - try { - const base = path.resolve(path.join(PORTAL_WEBROOT, targetDomain)); - const dest = path.resolve(base, targetSub.replace(/^\/+/, '')); - if (dest !== base && !dest.startsWith(base + path.sep)) throw new Error('Path traversal'); - cb(null, dest); - } catch (e: any) { cb(e, ''); } - void clientId; + // Resolve through realpath before handing multer a destination. + void portalResolveOwnedPath(clientId, targetDomain, targetSub, (req as any).teamAccountId || null) + .then(dest => cb(null, dest)) + .catch((e: any) => cb(e, '')); }, filename: (_req, file, cb) => cb(null, path.basename(file.originalname || 'upload').replace(/^\.+/, '_').slice(0, 255) || 'upload'), }), limits: { fileSize: 100 * 1024 * 1024 }, }); -function portalResolveOwnedPath(clientId: number, domain: string, sub: string, accountId: number | null = null): string { +// Resolve a tenant-supplied path inside their own docroot. The prefix check +// alone isn't enough — the tenant can write symlinks anywhere under their +// docroot — so assertSafeFileTarget re-checks containment after realpath. +async function portalResolveOwnedPath(clientId: number, domain: string, sub: string, accountId: number | null = null): Promise { + if (!PORTAL_DOMAIN_RE.test(domain)) throw new Error('Invalid domain'); if (!clientOwnsDomain(clientId, domain, accountId)) throw new Error('Not your domain'); const base = path.resolve(path.join(PORTAL_WEBROOT, domain)); const resolved = path.resolve(base, (sub || '').replace(/^\/+/, '')); if (resolved !== base && !resolved.startsWith(base + path.sep)) throw new Error('Path traversal not allowed'); if (/[$`"\\!]/.test(resolved)) throw new Error('Path contains invalid characters'); + await assertSafeFileTarget(resolved, base); return resolved; } +// Ownership and containment failures are 403; everything else stays 400. +const PORTAL_FORBIDDEN_ERRORS = new Set([ + 'Not your domain', + 'Path traversal not allowed', + 'Path contains invalid characters', + 'Resolved path points outside base directory', + 'Resolved parent points outside base directory', +]); + function portalFileError(res: Response, e: any) { const msg = e?.message || String(e); - return res.status(msg === 'Not your domain' ? 403 : 400).json({ error: msg }); + return res.status(PORTAL_FORBIDDEN_ERRORS.has(msg) ? 403 : 400).json({ error: msg }); } router.get('/files/:domain/list', clientAuth, async (req: Request, res: Response) => { try { - const dir = portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); + const dir = await portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); const entries = await fs.readdir(dir, { withFileTypes: true }); const items = await Promise.all(entries.map(async e => { try { @@ -1472,7 +1561,7 @@ router.get('/files/:domain/list', clientAuth, async (req: Request, res: Response router.get('/files/:domain/read', clientAuth, async (req: Request, res: Response) => { try { - const file = portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); + const file = await portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); const st = await fs.stat(file); if (st.size > 2 * 1024 * 1024) return res.status(413).json({ error: 'File too large to edit (> 2 MB)' }); res.json({ content: await fs.readFile(file, 'utf-8') }); @@ -1481,7 +1570,7 @@ router.get('/files/:domain/read', clientAuth, async (req: Request, res: Response router.post('/files/:domain/write', clientAuth, async (req: Request, res: Response) => { try { - const file = portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); + const file = await portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); await fs.writeFile(file, String(req.body.content ?? ''), 'utf-8'); res.json({ message: 'File saved' }); } catch (e: any) { portalFileError(res, e); } @@ -1489,7 +1578,7 @@ router.post('/files/:domain/write', clientAuth, async (req: Request, res: Respon router.post('/files/:domain/mkdir', clientAuth, async (req: Request, res: Response) => { try { - const dir = portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); + const dir = await portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); await fs.mkdir(dir, { recursive: true }); res.json({ message: 'Directory created' }); } catch (e: any) { portalFileError(res, e); } @@ -1497,7 +1586,7 @@ router.post('/files/:domain/mkdir', clientAuth, async (req: Request, res: Respon router.delete('/files/:domain/delete', clientAuth, async (req: Request, res: Response) => { try { - const target = portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); + const target = await portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.path || '', teamAccountScope(req)); await fs.rm(target, { recursive: true, force: true }); res.json({ message: 'Deleted' }); } catch (e: any) { portalFileError(res, e); } @@ -1505,8 +1594,8 @@ router.delete('/files/:domain/delete', clientAuth, async (req: Request, res: Res router.post('/files/:domain/rename', clientAuth, async (req: Request, res: Response) => { try { - const from = portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.from || '', teamAccountScope(req)); - const to = portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.to || '', teamAccountScope(req)); + const from = await portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.from || '', teamAccountScope(req)); + const to = await portalResolveOwnedPath((req as any).clientId, req.params.domain, req.body.to || '', teamAccountScope(req)); await fs.rename(from, to); res.json({ message: 'Renamed' }); } catch (e: any) { portalFileError(res, e); } @@ -1530,7 +1619,7 @@ router.post('/files/:domain/upload', clientAuth, router.get('/files/:domain/download', clientAuth, async (req: Request, res: Response) => { try { - const file = portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); + const file = await portalResolveOwnedPath((req as any).clientId, req.params.domain, (req.query.path as string) || '', teamAccountScope(req)); res.download(file); } catch (e: any) { portalFileError(res, e); } }); @@ -1590,7 +1679,7 @@ router.post('/htpasswd/:domain', clientAuth, async (req: Request, res: Response) if (typeof password !== 'string' || password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); // Reuse the file-manager's safePath to keep the protected directory // strictly under the client's own /var/www/ tree. - const absDir = portalResolveOwnedPath((req as any).clientId, domain, `public_html/${subpath}`, teamAccountScope(req)); + const absDir = await portalResolveOwnedPath((req as any).clientId, domain, `public_html/${subpath}`, teamAccountScope(req)); if (!existsSync(absDir)) return res.status(404).json({ error: 'Directory does not exist' }); const htpasswdFile = path.join(PORTAL_HTPW_DIR, Buffer.from(absDir).toString('hex') + '.htpasswd'); await fs.mkdir(PORTAL_HTPW_DIR, { recursive: true }); @@ -1610,7 +1699,7 @@ router.delete('/htpasswd/:domain', clientAuth, async (req: Request, res: Respons if (!clientOwnsDomain((req as any).clientId, domain, teamAccountScope(req))) return res.status(403).json({ error: 'Not your domain' }); const { subpath } = req.body; if (!subpath) return res.status(400).json({ error: 'subpath required' }); - const absDir = portalResolveOwnedPath((req as any).clientId, domain, `public_html/${subpath}`, teamAccountScope(req)); + const absDir = await portalResolveOwnedPath((req as any).clientId, domain, `public_html/${subpath}`, teamAccountScope(req)); const file = path.join(PORTAL_HTPW_DIR, Buffer.from(absDir).toString('hex') + '.htpasswd'); await fs.unlink(file).catch(() => {}); await fs.unlink(path.join(absDir, '.htaccess')).catch(() => {}); @@ -1760,9 +1849,11 @@ router.get('/htaccess/:domain', clientAuth, async (req: Request, res: Response) const { domain } = req.params; if (!PORTAL_DOMAIN_RE.test(domain)) return res.status(400).json({ error: 'Invalid domain' }); if (!clientOwnsDomain((req as any).clientId, domain, teamAccountScope(req))) return res.status(403).json({ error: 'Not your domain' }); - const file = path.join(PORTAL_WEBROOT, domain, 'public_html', '.htaccess'); - const content = await fs.readFile(file, 'utf-8').catch(() => ''); - res.json({ content }); + try { + const file = await portalResolveOwnedPath((req as any).clientId, domain, 'public_html/.htaccess', teamAccountScope(req)); + const content = await fs.readFile(file, 'utf-8').catch(() => ''); + res.json({ content }); + } catch (e: any) { portalFileError(res, e); } }); router.post('/htaccess/:domain', clientAuth, async (req: Request, res: Response) => { @@ -1778,9 +1869,11 @@ router.post('/htaccess/:domain', clientAuth, async (req: Request, res: Response) if (/\b(SetHandler\s+server-status|SetHandler\s+server-info)\b/i.test(content)) { return res.status(400).json({ error: 'Server-status / server-info handlers are not allowed' }); } - const file = path.join(PORTAL_WEBROOT, domain, 'public_html', '.htaccess'); - await fs.writeFile(file, content); - res.json({ success: true }); + try { + const file = await portalResolveOwnedPath((req as any).clientId, domain, 'public_html/.htaccess', teamAccountScope(req)); + await fs.writeFile(file, content); + res.json({ success: true }); + } catch (e: any) { portalFileError(res, e); } }); export default router; diff --git a/server/src/routes/command-execution.integration.test.ts b/server/src/routes/command-execution.integration.test.ts index d7099bb..729b7c0 100644 --- a/server/src/routes/command-execution.integration.test.ts +++ b/server/src/routes/command-execution.integration.test.ts @@ -486,6 +486,7 @@ describe('high-risk route command execution integration', () => { process.env.PHPMYADMIN_CONFIG_FILE = path.join(tmp, 'phpMyAdmin', 'config.inc.php'); process.env.PHPMYADMIN_DISTRO_CONF_FILE = path.join(tmp, 'httpd', 'phpMyAdmin.conf'); process.env.PHPMYADMIN_ALIAS = '/phpMyAdmin'; + process.env.PHPMYADMIN_SSO_TOKEN_DIR = path.join(tmp, 'pma-sso'); await fs.mkdir(path.dirname(process.env.PHPMYADMIN_DISTRO_CONF_FILE), { recursive: true }); await fs.writeFile(process.env.PHPMYADMIN_DISTRO_CONF_FILE, 'Alias /phpMyAdmin /usr/share/phpMyAdmin\nAlias /phpmyadmin /usr/share/phpMyAdmin\n'); const server = await appFor('/api/databases', './databases'); diff --git a/server/src/routes/cpanel-parity.integration.test.ts b/server/src/routes/cpanel-parity.integration.test.ts index 91708cb..5f4cb9d 100644 --- a/server/src/routes/cpanel-parity.integration.test.ts +++ b/server/src/routes/cpanel-parity.integration.test.ts @@ -72,8 +72,10 @@ describe('cPanel parity control-plane routes', () => { }); it('executes cPanel transfer import with file rollback and progress report', async () => { - const archive = `/var/backups/hostpanel-test-${Date.now()}.tar.gz`; - await fs.mkdir('/var/backups', { recursive: true }); + const backupDir = path.join(tmp, 'backups'); + await fs.mkdir(backupDir, { recursive: true }); + process.env.TRANSFER_ARCHIVE_ROOTS = backupDir; + const archive = path.join(backupDir, `hostpanel-test-${Date.now()}.tar.gz`); await fs.writeFile(archive, 'fake'); const entries = [ 'cpmove-demo/userdata/example.com', diff --git a/server/src/routes/feature-lists.ts b/server/src/routes/feature-lists.ts index 6f2180f..2944185 100644 --- a/server/src/routes/feature-lists.ts +++ b/server/src/routes/feature-lists.ts @@ -121,12 +121,22 @@ export function clientHasAnyAccountFeature(clientId: number | string | null | un return accounts.some(a => effectivePlanFeatures(a.plan_id).includes(feature)); } +// Privileges a reseller gets when no explicit reseller_privileges row exists: +// what they need to run their own customers. Server-wide keys (settings, +// admin-users, firewall, ...) require an explicit grant. +export const DEFAULT_RESELLER_FEATURES = [ + 'accounts', 'domains', 'subdomains', 'addon-domains', 'parked-domains', + 'redirects', 'error-pages', 'file-manager', 'backup-wizard', 'databases', + 'phpmyadmin', 'email-accounts', 'dkim', 'ftp', 'cron', 'ssl-advanced', + 'htpasswd', 'analytics', 'billing', 'team-users', 'wordpress', +]; + export function resellerFeatureKeysForUsername(username: string | undefined): string[] { if (!username) return []; const reseller: any = db.prepare(`SELECT r.id FROM resellers r JOIN admin_users u ON u.id=r.admin_user_id WHERE u.username=?`).get(username); if (!reseller) return []; const row: any = db.prepare('SELECT features FROM reseller_privileges WHERE reseller_id=?').get(reseller.id); - return row ? cleanFeatures(JSON.parse(row.features || '[]')) : allFeatureKeys(); + return row ? cleanFeatures(JSON.parse(row.features || '[]')) : cleanFeatures(DEFAULT_RESELLER_FEATURES); } export function enforceResellerPrivilege(feature: string) { @@ -149,8 +159,12 @@ export function requireClientFeature(feature: string) { } function accountIdFromPortalRequest(req: Request): number | null { - const explicit = req.params?.id || req.body?.accountId || req.query?.accountId; - if (explicit && /^\d+$/.test(String(explicit))) return Number(explicit); + // Deliberately does not read req.params.id — on portal routes `:id` is an + // invoice / spam-rule / autoresponder id, never an account id. + const hint = (req as any).portalAccountHint; + if (typeof hint === 'number') return hint; + const explicit = req.body?.accountId ?? req.query?.accountId; + if (explicit !== undefined && explicit !== null && /^\d+$/.test(String(explicit))) return Number(explicit); const domain = req.params?.domain || req.body?.domain || req.query?.domain; if (typeof domain === 'string' && domain) { const account: any = db.prepare('SELECT id FROM accounts WHERE domain=? AND client_id=?').get(domain, (req as any).clientId); diff --git a/server/src/routes/jobs.integration.test.ts b/server/src/routes/jobs.integration.test.ts index e40177c..ea49a68 100644 --- a/server/src/routes/jobs.integration.test.ts +++ b/server/src/routes/jobs.integration.test.ts @@ -318,8 +318,10 @@ describe('central background jobs API', () => { const provisionDone = await waitFor(async () => (await fetch(`${server.url}/api/jobs/${provisionJob.jobId}`)).json(), j => j.status === 'completed'); expect(provisionDone.type).toBe('webdav.provision'); - const archivePath = `/var/backups/hostpanel-jobs-${Date.now()}.tar.gz`; - await fs.mkdir('/var/backups', { recursive: true }); + const backupDir = path.join(tmp, 'backups'); + await fs.mkdir(backupDir, { recursive: true }); + process.env.TRANSFER_ARCHIVE_ROOTS = backupDir; + const archivePath = path.join(backupDir, `hostpanel-jobs-${Date.now()}.tar.gz`); await fs.writeFile(archivePath, 'fake'); const entries = ['cpmove-demo/userdata/example.com', 'cpmove-demo/homedir/public_html/index.html']; runFileMock.mockImplementation(async (cmd: string, args: string[]) => { diff --git a/server/src/routes/mail-routing.ts b/server/src/routes/mail-routing.ts index b581a74..376149d 100644 --- a/server/src/routes/mail-routing.ts +++ b/server/src/routes/mail-routing.ts @@ -61,7 +61,8 @@ router.post('/lists', async (req: Request, res: Response) => { if (!/^[a-z0-9_-]+$/i.test(name)) return res.status(400).json({ error: 'Invalid list name' }); if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/.test(domain)) return res.status(400).json({ error: 'Invalid domain' }); if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(admin_email)) return res.status(400).json({ error: 'Invalid admin email' }); - const safePass = (admin_password || 'changeme').replace(/[^a-zA-Z0-9!@#%^&*_+=.,-]/g, ''); + if (!admin_password || String(admin_password).length < 8) return res.status(400).json({ error: 'admin_password is required (at least 8 characters)' }); + const safePass = String(admin_password).replace(/[^a-zA-Z0-9!@#%^&*_+=.,-]/g, ''); try { await runFile('newlist', ['-q', `${name}@${domain}`, admin_email, safePass]).catch(() => ({ stdout: '', stderr: '' })); const r = db.prepare('INSERT INTO mailing_lists (name, domain, description, admin_email) VALUES (?, ?, ?, ?)').run(name, domain, description || '', admin_email); diff --git a/server/src/routes/portal-tenant-isolation.integration.test.ts b/server/src/routes/portal-tenant-isolation.integration.test.ts new file mode 100644 index 0000000..0d01159 --- /dev/null +++ b/server/src/routes/portal-tenant-isolation.integration.test.ts @@ -0,0 +1,175 @@ +/** + * Client-portal tenant isolation regressions. The portal is reachable by + * hosting customers while the panel runs as root, so each test here pins a + * boundary between one tenant and everything else on the box. + */ +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import bcrypt from 'bcryptjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../utils/process-runner', () => ({ + runFile: vi.fn(async (cmd: string) => { + if (cmd === 'getent') return { stdout: 'alice:x:1001::/home/alice:/bin/bash\nalice_shop:x:1002::/home/alice_shop:/bin/bash\n', stderr: '' }; + if (cmd === 'id') return { stdout: 'uid=1002', stderr: '' }; + return { stdout: '', stderr: '' }; + }), +})); + +function listen(app: express.Express): Promise<{ url: string; close: () => Promise }> { + return new Promise(resolve => { + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('Missing address'); + resolve({ url: `http://127.0.0.1:${addr.port}`, close: () => new Promise((res, rej) => server.close(err => err ? rej(err) : res())) }); + }); + }); +} + +describe('client portal tenant isolation', () => { + let tmp = ''; + let closeServer: (() => Promise) | undefined; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hostpanel-tenant-')); + process.env.DATA_DIR = tmp; + process.env.JWT_SECRET = 'test-tenant-secret'; + process.env.WEBROOT = path.join(tmp, 'www'); + process.env.HOME_ROOT = path.join(tmp, 'home'); + vi.resetModules(); + }); + + afterEach(async () => { + if (closeServer) await closeServer(); + closeServer = undefined; + await fs.rm(tmp, { recursive: true, force: true }); + delete process.env.HOME_ROOT; + vi.resetModules(); + }); + + async function portalApp() { + const db = (await import('../db')).default; + const portal = (await import('./client-portal')).default; + const app = express(); app.use(express.json()); app.use('/api/portal', portal); + const server = await listen(app); closeServer = server.close; + return { db, url: server.url }; + } + + async function loginAs(url: string, email: string, password = 'password123') { + const res = await fetch(`${url}/api/portal/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const body = await res.json(); + return body.token as string; + } + + it('refuses to follow a tenant-planted symlink out of their docroot', async () => { + const { db, url } = await portalApp(); + const hash = await bcrypt.hash('password123', 4); + const clientId = Number(db.prepare("INSERT INTO clients (name,email,portal_enabled,password_hash) VALUES ('Acme','victim@example.com',1,?)").run(hash).lastInsertRowid); + db.prepare("INSERT INTO accounts (username,domain,client_id,status) VALUES ('acme','example.com',?,'active')").run(clientId); + + const webroot = process.env.WEBROOT!; + await fs.mkdir(path.join(webroot, 'example.com', 'public_html'), { recursive: true }); + const secretDir = path.join(tmp, 'outside'); + await fs.mkdir(secretDir, { recursive: true }); + await fs.writeFile(path.join(secretDir, 'shadow'), 'root:$6$SECRET:...\n'); + + await fs.symlink(secretDir, path.join(webroot, 'example.com', 'public_html', 'escape')); + + const auth = { authorization: `Bearer ${await loginAs(url, 'victim@example.com')}` }; + + const read = await fetch(`${url}/api/portal/files/example.com/read?path=${encodeURIComponent('public_html/escape/shadow')}`, { headers: auth }); + expect(read.status, JSON.stringify(await read.clone().json())).toBe(403); + + const write = await fetch(`${url}/api/portal/files/example.com/write`, { + method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, + body: JSON.stringify({ path: 'public_html/escape/pwned.txt', content: 'attacker was here' }), + }); + expect(write.status).toBe(403); + await expect(fs.readFile(path.join(secretDir, 'pwned.txt'), 'utf8')).rejects.toThrow(); + + const list = await fetch(`${url}/api/portal/files/example.com/list?path=${encodeURIComponent('public_html/escape')}`, { headers: auth }); + expect(list.status).toBe(403); + }); + + it('does not let one tenant reach another whose username shares their prefix', async () => { + const { db, url } = await portalApp(); + const hash = await bcrypt.hash('password123', 4); + + // `alice` and `alice_shop` belong to different customers here. + const attacker = Number(db.prepare("INSERT INTO clients (name,email,portal_enabled,password_hash) VALUES ('Attacker','alice@example.com',1,?)").run(hash).lastInsertRowid); + db.prepare("INSERT INTO accounts (username,domain,client_id,status) VALUES ('alice','alice.com',?,'active')").run(attacker); + const victim = Number(db.prepare("INSERT INTO clients (name,email,portal_enabled,password_hash) VALUES ('Victim','shop@example.com',1,?)").run(hash).lastInsertRowid); + db.prepare("INSERT INTO accounts (username,domain,client_id,status) VALUES ('alice_shop','shop.example',?,'active')").run(victim); + + const auth = { authorization: `Bearer ${await loginAs(url, 'alice@example.com')}`, 'content-type': 'application/json' }; + + const ssh = await fetch(`${url}/api/portal/sshkeys`, { + method: 'POST', headers: auth, + body: JSON.stringify({ user: 'alice_shop', key: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAttacker attacker@evil' }), + }); + expect(ssh.status, JSON.stringify(await ssh.clone().json())).toBe(403); + + const cron = await fetch(`${url}/api/portal/cron`, { + method: 'POST', headers: auth, + body: JSON.stringify({ user: 'alice_shop', schedule: '* * * * *', command: '/bin/id' }), + }); + expect(cron.status).toBe(403); + + // Their own account still works — the fix must not over-block. + const own = await fetch(`${url}/api/portal/sshkeys`, { + method: 'POST', headers: auth, + body: JSON.stringify({ user: 'alice_nonexistent_suffix', key: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOwn owner@self' }), + }); + // Resolves to the caller's own account, so it fails on "no such OS user". + expect(own.status).not.toBe(403); + }); + + it('will not write SSH keys through a symlinked home or .ssh directory', async () => { + const { db, url } = await portalApp(); + const hash = await bcrypt.hash('password123', 4); + const clientId = Number(db.prepare("INSERT INTO clients (name,email,portal_enabled,password_hash) VALUES ('Acme','ssh@example.com',1,?)").run(hash).lastInsertRowid); + db.prepare("INSERT INTO accounts (username,domain,client_id,status) VALUES ('bob','bob.com',?,'active')").run(clientId); + + // Stand-in for /root/.ssh. + const rootSsh = path.join(tmp, 'root-ssh'); + await fs.mkdir(rootSsh, { recursive: true }); + await fs.mkdir(path.join(process.env.HOME_ROOT!, 'bob'), { recursive: true }); + await fs.symlink(rootSsh, path.join(process.env.HOME_ROOT!, 'bob', '.ssh')); + + const auth = { authorization: `Bearer ${await loginAs(url, 'ssh@example.com')}`, 'content-type': 'application/json' }; + const res = await fetch(`${url}/api/portal/sshkeys`, { + method: 'POST', headers: auth, + body: JSON.stringify({ user: 'bob', key: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEscalate attacker@evil' }), + }); + expect(res.status).toBe(400); + await expect(fs.readFile(path.join(rootSsh, 'authorized_keys'), 'utf8')).rejects.toThrow(); + }); + + it('rejects a multi-line SSH key that would smuggle extra authorized_keys entries', async () => { + const { db, url } = await portalApp(); + const hash = await bcrypt.hash('password123', 4); + const clientId = Number(db.prepare("INSERT INTO clients (name,email,portal_enabled,password_hash) VALUES ('Acme','multi@example.com',1,?)").run(hash).lastInsertRowid); + db.prepare("INSERT INTO accounts (username,domain,client_id,status) VALUES ('carol','carol.com',?,'active')").run(clientId); + await fs.mkdir(path.join(process.env.HOME_ROOT!, 'carol'), { recursive: true }); + + const auth = { authorization: `Bearer ${await loginAs(url, 'multi@example.com')}`, 'content-type': 'application/json' }; + const res = await fetch(`${url}/api/portal/sshkeys`, { + method: 'POST', headers: auth, + body: JSON.stringify({ user: 'carol', key: 'ssh-ed25519 AAAAKey one@host\ncommand="/bin/sh" ssh-ed25519 AAAAKey2 two@host' }), + }); + expect(res.status).toBe(400); + }); + + it('fails closed on a portal route that is not mapped to a feature', async () => { + const { url } = await portalApp(); + const { default: portalRouter } = await import('./client-portal'); + expect(portalRouter).toBeTruthy(); + // /api/portal/nope is unmapped; it must not fall through ungated. + const res = await fetch(`${url}/api/portal/nope`, { headers: { authorization: 'Bearer garbage' } }); + expect([401, 403, 404]).toContain(res.status); + }); +}); diff --git a/server/src/routes/reseller-isolation.integration.test.ts b/server/src/routes/reseller-isolation.integration.test.ts new file mode 100644 index 0000000..7da0105 --- /dev/null +++ b/server/src/routes/reseller-isolation.integration.test.ts @@ -0,0 +1,176 @@ +/** + * Reseller tenancy regressions: a reseller must only see and act on the + * accounts carrying their own reseller_id. + */ +import express from 'express'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import jwt from 'jsonwebtoken'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../utils/process-runner', () => ({ runFile: vi.fn(async () => ({ stdout: '', stderr: '' })) })); + +const JWT_SECRET = 'test-reseller-secret'; + +function listen(app: express.Express): Promise<{ url: string; close: () => Promise }> { + return new Promise(resolve => { + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('Missing address'); + resolve({ url: `http://127.0.0.1:${addr.port}`, close: () => new Promise((res, rej) => server.close(err => err ? rej(err) : res())) }); + }); + }); +} + +describe('reseller tenancy isolation', () => { + let tmp = ''; + let closeServer: (() => Promise) | undefined; + + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hostpanel-reseller-')); + process.env.DATA_DIR = tmp; + process.env.JWT_SECRET = JWT_SECRET; + process.env.WEBROOT = path.join(tmp, 'www'); + process.env.VHOST_DIR = path.join(tmp, 'vhost'); + await fs.mkdir(process.env.VHOST_DIR, { recursive: true }); + vi.resetModules(); + }); + + afterEach(async () => { + if (closeServer) await closeServer(); + closeServer = undefined; + await fs.rm(tmp, { recursive: true, force: true }); + vi.resetModules(); + }); + + function token(username: string, role: string) { + return jwt.sign({ username, role }, JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' }); + } + + async function seed() { + const db = (await import('../db')).default; + const { authenticateToken, blockPortalRoles } = await import('../middleware/auth'); + const { enforceResellerPrivilege } = await import('./feature-lists'); + const accountRoutes = (await import('./accounts')).default; + const settingsRoutes = (await import('./settings')).default; + + // Two resellers with one account each, plus one unowned admin account. + const mkReseller = (username: string) => { + const adminId = Number(db.prepare("INSERT INTO admin_users (username,email,password_hash,role) VALUES (?,?,'x','reseller')").run(username, `${username}@example.com`).lastInsertRowid); + return Number(db.prepare('INSERT INTO resellers (admin_user_id, company, alloc_accounts) VALUES (?,?,?)').run(adminId, username, 5).lastInsertRowid); + }; + const r1 = mkReseller('rone'); + const r2 = mkReseller('rtwo'); + + const mine = Number(db.prepare("INSERT INTO accounts (username,domain,status,reseller_id) VALUES ('mine','mine.com','active',?)").run(r1).lastInsertRowid); + const theirs = Number(db.prepare("INSERT INTO accounts (username,domain,status,reseller_id) VALUES ('theirs','theirs.com','active',?)").run(r2).lastInsertRowid); + const adminOwned = Number(db.prepare("INSERT INTO accounts (username,domain,status,reseller_id) VALUES ('house','house.com','active',NULL)").run().lastInsertRowid); + + const app = express(); + app.use(express.json()); + app.use('/api/accounts', authenticateToken, blockPortalRoles, enforceResellerPrivilege('accounts'), accountRoutes); + app.use('/api/settings', authenticateToken, blockPortalRoles, enforceResellerPrivilege('settings'), settingsRoutes); + const server = await listen(app); closeServer = server.close; + return { url: server.url, db, ids: { mine, theirs, adminOwned }, r1, r2 }; + } + + it('lists only the accounts a reseller owns', async () => { + const { url } = await seed(); + const res = await fetch(`${url}/api/accounts`, { headers: { authorization: `Bearer ${token('rone', 'reseller')}` } }); + expect(res.status).toBe(200); + const rows = await res.json(); + expect(rows.map((r: any) => r.domain).sort()).toEqual(['mine.com']); + }); + + it('still shows an admin every account', async () => { + const { url } = await seed(); + const res = await fetch(`${url}/api/accounts`, { headers: { authorization: `Bearer ${token('root', 'superadmin')}` } }); + const rows = await res.json(); + expect(rows.map((r: any) => r.domain).sort()).toEqual(['house.com', 'mine.com', 'theirs.com']); + }); + + it('hides another reseller\'s account behind a 404 rather than confirming it exists', async () => { + const { url, ids } = await seed(); + const auth = { authorization: `Bearer ${token('rone', 'reseller')}` }; + expect((await fetch(`${url}/api/accounts/${ids.theirs}`, { headers: auth })).status).toBe(404); + expect((await fetch(`${url}/api/accounts/${ids.adminOwned}`, { headers: auth })).status).toBe(404); + expect((await fetch(`${url}/api/accounts/${ids.mine}`, { headers: auth })).status).toBe(200); + }); + + it('refuses destructive actions against accounts outside the reseller\'s scope', async () => { + const { url, db, ids } = await seed(); + const auth = { authorization: `Bearer ${token('rone', 'reseller')}`, 'content-type': 'application/json' }; + + expect((await fetch(`${url}/api/accounts/${ids.theirs}`, { method: 'DELETE', headers: auth })).status).toBe(404); + expect((await fetch(`${url}/api/accounts/${ids.theirs}/suspend`, { method: 'POST', headers: auth })).status).toBe(404); + expect((await fetch(`${url}/api/accounts/${ids.theirs}/status`, { + method: 'PATCH', headers: auth, body: JSON.stringify({ status: 'terminated' }), + })).status).toBe(404); + expect((await fetch(`${url}/api/accounts/${ids.theirs}/export`, { method: 'POST', headers: auth })).status).toBe(404); + + const victim: any = db.prepare('SELECT status FROM accounts WHERE id=?').get(ids.theirs); + expect(victim.status).toBe('active'); + expect(db.prepare('SELECT COUNT(*) AS n FROM accounts').get()).toEqual({ n: 3 }); + }); + + it('stamps a reseller-created account into that reseller\'s scope', async () => { + const { url, db, r1 } = await seed(); + const res = await fetch(`${url}/api/accounts`, { + method: 'POST', + headers: { authorization: `Bearer ${token('rone', 'reseller')}`, 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'fresh', domain: 'fresh.com', password: 'supersecret1' }), + }); + expect(res.status).toBe(200); + const row: any = db.prepare("SELECT reseller_id FROM accounts WHERE domain='fresh.com'").get(); + expect(row.reseller_id).toBe(r1); + }); + + it('enforces the reseller account allocation', async () => { + const { url, db, r1 } = await seed(); + db.prepare('UPDATE resellers SET alloc_accounts=1 WHERE id=?').run(r1); // already owns 1 + const res = await fetch(`${url}/api/accounts`, { + method: 'POST', + headers: { authorization: `Bearer ${token('rone', 'reseller')}`, 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'overflow', domain: 'overflow.com', password: 'supersecret1' }), + }); + expect(res.status).toBe(403); + expect((await res.json()).error).toMatch(/allocation exhausted/i); + }); + + it('keeps installation-wide settings out of reseller hands', async () => { + const { url, db } = await seed(); + const res = await fetch(`${url}/api/settings`, { + method: 'PUT', + headers: { authorization: `Bearer ${token('rone', 'reseller')}`, 'content-type': 'application/json' }, + body: JSON.stringify({ stripe_secret_key: 'sk_live_attacker', smtp_host: 'evil.example' }), + }); + expect(res.status).toBe(403); + const stripe = db.prepare("SELECT value FROM settings WHERE key='stripe_secret_key'").get() as { value: string } | undefined; + expect(stripe?.value ?? '').not.toBe('sk_live_attacker'); + }); + + it('gives a brand-new reseller a restricted default privilege set, not everything', async () => { + await seed(); + const { resellerFeatureKeysForUsername } = await import('./feature-lists'); + const features = resellerFeatureKeysForUsername('rone'); + expect(features).toContain('accounts'); + expect(features).toContain('databases'); + expect(features).not.toContain('settings'); + expect(features).not.toContain('admin-users'); + expect(features).not.toContain('firewall'); + expect(features).not.toContain('api-tokens'); + }); + + it('fails a reseller-role token with no resellers row closed at both layers', async () => { + const { url, db } = await seed(); + db.prepare("INSERT INTO admin_users (username,email,password_hash,role) VALUES ('orphan','o@example.com','x','reseller')").run(); + + // Privilege layer denies first. + const res = await fetch(`${url}/api/accounts`, { headers: { authorization: `Bearer ${token('orphan', 'reseller')}` } }); + expect(res.status).toBe(403); + + // And the scope layer resolves to a sentinel matching no account. + const { requestResellerScope } = await import('../utils/reseller-scope'); + expect(requestResellerScope({ user: { username: 'orphan', role: 'reseller' } } as any)).toBe(-1); + }); +}); diff --git a/server/src/routes/scanner-jobs.integration.test.ts b/server/src/routes/scanner-jobs.integration.test.ts index 467fba2..f83b527 100644 --- a/server/src/routes/scanner-jobs.integration.test.ts +++ b/server/src/routes/scanner-jobs.integration.test.ts @@ -6,7 +6,9 @@ */ import express from 'express'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { chmodSync, existsSync, unlinkSync, writeFileSync } from 'fs'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; import { runFile } from '../utils/process-runner'; vi.mock('../utils/process-runner', () => ({ @@ -29,20 +31,28 @@ vi.mock('child_process', async (importOriginal) => { }); const runFileMock = vi.mocked(runFile); -const fakePm2Path = '/usr/local/bin/pm2'; -let createdFakePm2 = false; + +// Stub pm2 in a throwaway dir prepended to PATH, so the suite never writes +// into a system bin directory. +let pm2BinDir = ''; +let originalPath: string | undefined; function ensurePm2PresentForMockedExecFile() { - if (existsSync(fakePm2Path)) return; + if (pm2BinDir) return; + pm2BinDir = mkdtempSync(path.join(os.tmpdir(), 'hostpanel-fake-pm2-')); + const fakePm2Path = path.join(pm2BinDir, 'pm2'); writeFileSync(fakePm2Path, '#!/bin/sh\nexit 0\n'); chmodSync(fakePm2Path, 0o755); - createdFakePm2 = true; + originalPath = process.env.PATH; + process.env.PATH = `${pm2BinDir}${path.delimiter}${originalPath ?? ''}`; } function cleanupFakePm2() { - if (!createdFakePm2) return; - try { unlinkSync(fakePm2Path); } catch (_) {} - createdFakePm2 = false; + if (!pm2BinDir) return; + if (originalPath !== undefined) process.env.PATH = originalPath; + try { rmSync(pm2BinDir, { recursive: true, force: true }); } catch (_) {} + pm2BinDir = ''; + originalPath = undefined; } function listen(app: express.Express): Promise<{ url: string; close: () => Promise }> { diff --git a/server/src/routes/scripts.ts b/server/src/routes/scripts.ts index e96837d..ddd3655 100644 --- a/server/src/routes/scripts.ts +++ b/server/src/routes/scripts.ts @@ -127,6 +127,11 @@ router.post('/install', async (req: AuthRequest, res: Response) => { res.status(400).json({ error: 'WordPress install requires dbName, dbUser, and dbPass — these go into wp-config.php' }); return; } + // No default — a fallback password would publish a known WordPress login. + if (script === 'wordpress' && (!adminPass || String(adminPass).length < 8)) { + res.status(400).json({ error: 'WordPress install requires adminPass (at least 8 characters)' }); + return; + } const installPath = path.join(WEBROOT, domain, 'public_html'); const meta = SCRIPTS[script]; @@ -203,7 +208,7 @@ router.post('/install', async (req: AuthRequest, res: Response) => { `--url=${siteUrl}`, `--title=${siteTitle || 'My Site'}`, `--admin_user=${adminUser || 'admin'}`, - `--admin_password=${adminPass || 'changeme'}`, + `--admin_password=${adminPass}`, `--admin_email=${adminEmail || 'admin@example.com'}`, '--skip-email', ]).catch(() => {}); diff --git a/server/src/routes/security-extra.ts b/server/src/routes/security-extra.ts index a0b9288..95d137f 100644 --- a/server/src/routes/security-extra.ts +++ b/server/src/routes/security-extra.ts @@ -103,7 +103,8 @@ router.post('/change-password', async (req: AuthRequest, res: Response) => { if (dbUser?.password_hash) { valid = await bcrypt.compare(currentPassword, dbUser.password_hash); } else { - const envHash = process.env.ADMIN_PASS_HASH || bcrypt.hashSync('changeme', 10); + const envHash = process.env.ADMIN_PASS_HASH; + if (!envHash) return res.status(409).json({ error: 'No password is configured for this admin' }); valid = await bcrypt.compare(currentPassword, envHash); } diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index c32cf69..86f7aac 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -21,8 +21,14 @@ const logoUpload = multer({ storage: logoStorage, limits: { fileSize: 2 * 1024 * cb(null, /image\/(png|jpeg|gif|svg\+xml|webp)/.test(file.mimetype)); } }); +import { requireRole } from '../middleware/auth'; + const router = Router(); +// Installation-wide settings (Stripe, PayPal, SMTP credentials). Writes are +// admin-only regardless of feature-list grants; GET already strips secrets. +const adminOnly = requireRole('superadmin', 'admin'); + /* ── Get all settings ────────────────────────────────────── */ router.get('/', (_req: Request, res: Response) => { @@ -48,7 +54,7 @@ const ALLOWED_KEYS = new Set([ 'panel_2fa_required', ]); -router.put('/', (req: Request, res: Response) => { +router.put('/', adminOnly, (req: Request, res: Response) => { const upsert = db.prepare("INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at"); const updates = db.transaction(() => { for (const [key, value] of Object.entries(req.body)) { @@ -62,7 +68,7 @@ router.put('/', (req: Request, res: Response) => { /* ── Test SMTP ───────────────────────────────────────────── */ -router.post('/test-smtp', async (req: Request, res: Response) => { +router.post('/test-smtp', adminOnly, async (req: Request, res: Response) => { const { smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from, smtp_secure, to } = req.body; if (!smtp_host || !to) return res.status(400).json({ error: 'smtp_host and to are required' }); try { @@ -84,7 +90,7 @@ router.post('/test-smtp', async (req: Request, res: Response) => { /* ── Logo upload ─────────────────────────────────────────── */ -router.post('/logo', logoUpload.single('logo'), (req: Request, res: Response) => { +router.post('/logo', adminOnly, logoUpload.single('logo'), (req: Request, res: Response) => { if (!req.file) return res.status(400).json({ error: 'No image uploaded or unsupported format' }); const logoUrl = `/api/settings/logo?t=${Date.now()}`; db.prepare("INSERT INTO settings (key, value, updated_at) VALUES ('company_logo', ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at") @@ -111,7 +117,7 @@ router.get('/branding', (_req: Request, res: Response) => { }); }); -router.delete('/logo', (req: Request, res: Response) => { +router.delete('/logo', adminOnly, (req: Request, res: Response) => { const logoPath = path.join(DATA_DIR, 'uploads', 'logo.png'); try { if (existsSync(logoPath)) require('fs').unlinkSync(logoPath); @@ -133,7 +139,7 @@ router.get('/relay', async (_req: Request, res: Response) => { } catch (err: any) { res.status(500).json({ error: err.message }); } }); -router.put('/relay', async (req: Request, res: Response) => { +router.put('/relay', adminOnly, async (req: Request, res: Response) => { const { relayhost, sasl_user, sasl_pass } = req.body; if (!relayhost) return res.status(400).json({ error: 'relayhost required' }); // CRLF in any of these lets the caller inject arbitrary postfix config diff --git a/server/src/routes/transfer-import.ts b/server/src/routes/transfer-import.ts index 3bdcfee..72c9da1 100644 --- a/server/src/routes/transfer-import.ts +++ b/server/src/routes/transfer-import.ts @@ -24,9 +24,19 @@ const DB_HOST = process.env.DB_HOST || '127.0.0.1'; const DB_ROOT_USER = process.env.DB_ROOT_USER || 'root'; const DB_ROOT_PASS = process.env.DB_ROOT_PASS || ''; +// Roots a transfer archive may be read from (colon separated). Read at call +// time so the value isn't frozen at import. +function archiveRoots(): string[] { + return (process.env.TRANSFER_ARCHIVE_ROOTS || '/root:/home:/var/backups') + .split(':').filter(Boolean).map(r => path.resolve(r)); +} + +function archiveRootsDescription(): string { return archiveRoots().join(', '); } + function safeArchive(p: string) { const full = path.resolve(p || ''); - return (full.startsWith('/root/') || full.startsWith('/home/') || full.startsWith('/var/backups/')) && /\.(tar\.gz|tgz|tar)$/.test(full); + const inRoot = archiveRoots().some(root => full === root || full.startsWith(root + path.sep)); + return inRoot && /\.(tar\.gz|tgz|tar)$/.test(full); } function safeArchiveEntry(entry: string): boolean { return !!entry && !entry.startsWith('/') && !entry.includes('..') && !entry.includes('\0'); } function domainOk(s: string) { return /^[a-zA-Z0-9][a-zA-Z0-9.-]{1,253}$/.test(s || ''); } @@ -144,7 +154,7 @@ router.get('/:id', (req: Request, res: Response) => { router.post('/inspect', async (req: Request, res: Response) => { const archivePath = String(req.body?.archivePath || ''); - if (!safeArchive(archivePath)) return res.status(400).json({ error: 'Archive path must be a .tar/.tar.gz/.tgz under /root, /home or /var/backups' }); + if (!safeArchive(archivePath)) return res.status(400).json({ error: `Archive path must be a .tar/.tar.gz/.tgz under ${archiveRootsDescription()}` }); if (!existsSync(archivePath)) return res.status(404).json({ error: 'Archive not found' }); try { const files = await listArchive(archivePath); diff --git a/server/src/stress/README.md b/server/src/stress/README.md new file mode 100644 index 0000000..9cd4de4 --- /dev/null +++ b/server/src/stress/README.md @@ -0,0 +1,46 @@ +# HostPanel stress harness + +Load and resilience scenarios run against a **live** HostPanel API. They answer +questions the unit and integration suites cannot: does the rate limiter actually +engage, does an unauthenticated flood take the panel down with it, does the +server survive held-open sockets, what does p99 latency look like under +sustained concurrency. + +## Running + +```bash +npm run stress --workspace=server +``` + +That boots a throwaway server on a random port with a temporary `DATA_DIR`, +runs every scenario, and exits non-zero if any of them fails. + +To point the harness at a server you started yourself: + +```bash +BASE=http://127.0.0.1:3001 ADMIN_TOKEN= node dist/stress/run.js +``` + +## Scenario budgets + +Each scenario asserts a threshold rather than just printing numbers, so a +performance regression fails the run: + +| Scenario | Asserts | +| --- | --- | +| Global rate limit | a 400-request burst produces 429s | +| Login flood | unrelated endpoints stay under 1s while login is flooded | +| Oversized body | a 5 MB JSON body is refused with 413, not buffered | +| Deeply nested JSON | parser rejects it and the server stays responsive | +| Oversized query string | handled without wedging the process | +| Half-open sockets | 200 held-open connections don't block real traffic | +| Heavy endpoint limiter | `/accounts/:id/usage` is capped | +| SQLite write concurrency | 80 concurrent writes produce no 5xx | +| Sustained mixed load | p99 < 2s and error rate < 5% over 8s at 20 concurrent | + +Each scenario uses its own `X-Forwarded-For` bucket so one scenario's rate-limit +budget doesn't bleed into the next. + +The login-flood scenario is the canary for anything blocking added to the +request path: a synchronous hash on the reject path is enough to push unrelated +endpoints from single-digit milliseconds into seconds. diff --git a/server/src/stress/run.ts b/server/src/stress/run.ts new file mode 100644 index 0000000..3963c8c --- /dev/null +++ b/server/src/stress/run.ts @@ -0,0 +1,258 @@ +/** + * HostPanel stress / resilience harness — see ./README.md. + * + * Boots a throwaway server (or targets BASE), runs every scenario, and exits + * non-zero when a threshold is missed. Each scenario uses its own + * X-Forwarded-For bucket so rate-limit budgets don't bleed between them. + */ +import { spawn, ChildProcess } from 'child_process'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import net from 'net'; +import bcrypt from 'bcryptjs'; + +interface Result { name: string; ok: boolean; detail: string } + +const results: Result[] = []; +let bucket = 0; + +function record(name: string, ok: boolean, detail: string) { + results.push({ name, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}\n ${detail}`); +} + +async function timed(fn: () => Promise): Promise<{ r: T; ms: number }> { + const t = process.hrtime.bigint(); + const r = await fn(); + return { r, ms: Number(process.hrtime.bigint() - t) / 1e6 }; +} + +function authHeaders(token: string): Record { + return { authorization: `Bearer ${token}`, 'X-Forwarded-For': `10.${bucket % 250}.0.1` }; +} + +async function waitForHealth(base: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await fetch(`${base}/healthz`); + if (r.ok) return; + } catch { /* not up yet */ } + await new Promise(r => setTimeout(r, 250)); + } + throw new Error(`Server did not become healthy within ${timeoutMs}ms`); +} + +/* ── Scenarios ────────────────────────────────────────────────────────── */ + +async function globalRateLimit(base: string, token: string) { + const N = 400; + const codes: Record = {}; + await Promise.all(Array.from({ length: N }, async () => { + const res = await fetch(`${base}/api/health/summary`, { headers: authHeaders(token) }).catch(() => ({ status: 0 } as Response)); + codes[res.status] = (codes[res.status] || 0) + 1; + })); + record('global rate limit engages under burst', (codes[429] || 0) > 0, + `${N} concurrent req → ${JSON.stringify(codes)}`); +} + +async function loginFlood(base: string) { + const idle = await timed(() => fetch(`${base}/healthz`)); + const flood = Promise.all(Array.from({ length: 60 }, () => + fetch(`${base}/api/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'wrong-password-attempt' }), + }).catch(() => null))); + await new Promise(r => setTimeout(r, 50)); + const during = await timed(() => fetch(`${base}/healthz`)); + await flood; + record('public endpoint stays responsive during login flood', during.ms < 1000, + `/healthz idle=${idle.ms.toFixed(1)}ms during-flood=${during.ms.toFixed(1)}ms`); +} + +async function oversizedBody(base: string, token: string) { + const big = JSON.stringify({ path: '/x', content: 'A'.repeat(5 * 1024 * 1024) }); + const { r, ms } = await timed(() => fetch(`${base}/api/files/write`, { + method: 'POST', headers: { ...authHeaders(token), 'content-type': 'application/json' }, body: big, + }).catch(() => ({ status: 0 } as Response))); + record('5MB JSON body rejected by the 1MB cap', r.status === 413, + `status=${r.status} in ${ms.toFixed(0)}ms (expected 413)`); +} + +async function deepJson(base: string, token: string) { + const depth = 50_000; + const body = '['.repeat(depth) + ']'.repeat(depth); + const { r } = await timed(() => fetch(`${base}/api/files/write`, { + method: 'POST', headers: { ...authHeaders(token), 'content-type': 'application/json' }, body, + }).catch(() => ({ status: 0 } as Response))); + const after = await timed(() => fetch(`${base}/healthz`)); + record('deeply nested JSON does not wedge the server', after.r.ok && after.ms < 1000, + `parse status=${r.status}; /healthz after = ${after.ms.toFixed(0)}ms`); +} + +async function hugeQuery(base: string, token: string) { + const q = 'path=' + 'a'.repeat(60_000); + const { r } = await timed(() => fetch(`${base}/api/files/list?${q}`, { headers: authHeaders(token) }) + .catch(() => ({ status: 0 } as Response))); + const after = await timed(() => fetch(`${base}/healthz`)); + record('oversized query string handled cleanly', after.r.ok, + `status=${r.status}; server alive after (${after.ms.toFixed(0)}ms)`); +} + +async function halfOpenSockets(base: string) { + const url = new URL(base); + const sockets: net.Socket[] = []; + const N = 200; + await Promise.all(Array.from({ length: N }, () => new Promise(resolve => { + const s = net.connect(Number(url.port), url.hostname, () => { + s.write('GET /healthz HTTP/1.1\r\nHost: x\r\n'); // deliberately unterminated + sockets.push(s); resolve(); + }); + s.on('error', () => resolve()); + setTimeout(resolve, 2000); + }))); + const probe = await timed(() => fetch(`${base}/healthz`).catch(() => ({ ok: false } as Response))); + for (const s of sockets) s.destroy(); + record('server survives 200 half-open connections', probe.r.ok === true, + `${sockets.length} half-open sockets held; /healthz in ${probe.ms.toFixed(0)}ms`); +} + +async function heavyLimiter(base: string, token: string) { + const codes: Record = {}; + for (let i = 0; i < 12; i++) { + const res = await fetch(`${base}/api/accounts/1/usage`, { headers: authHeaders(token) }).catch(() => ({ status: 0 } as Response)); + codes[res.status] = (codes[res.status] || 0) + 1; + } + record('heavy account endpoint is rate limited', (codes[429] || 0) > 0, + `12 sequential req → ${JSON.stringify(codes)}`); +} + +async function sqliteConcurrency(base: string, token: string) { + const N = 80; + const codes: Record = {}; + await Promise.all(Array.from({ length: N }, (_, i) => + fetch(`${base}/api/settings`, { + method: 'PUT', headers: { ...authHeaders(token), 'content-type': 'application/json' }, + body: JSON.stringify({ company_name: `stress-${i}` }), + }).then(r => { codes[r.status] = (codes[r.status] || 0) + 1; }) + .catch(() => { codes[0] = (codes[0] || 0) + 1; }))); + const alive = await fetch(`${base}/healthz`).then(r => r.ok).catch(() => false); + record('concurrent SQLite writes produce no 5xx', !codes[500] && alive, + `${N} concurrent PUT /api/settings → ${JSON.stringify(codes)}`); +} + +async function sustainedLoad(base: string) { + const endpoints = ['/healthz', '/api/settings/branding']; + const DURATION_MS = 8000; + const CONCURRENCY = 20; + const lat: number[] = []; + let errors = 0; let n = 0; + const stop = Date.now() + DURATION_MS; + await Promise.all(Array.from({ length: CONCURRENCY }, async () => { + while (Date.now() < stop) { + const ep = endpoints[n++ % endpoints.length]; + const t = process.hrtime.bigint(); + try { + const r = await fetch(`${base}${ep}`); + if (!r.ok && r.status !== 429) errors++; + await r.arrayBuffer(); + } catch { errors++; } + lat.push(Number(process.hrtime.bigint() - t) / 1e6); + } + })); + lat.sort((a, b) => a - b); + const p50 = lat[Math.floor(lat.length * 0.5)]; + const p99 = lat[Math.floor(lat.length * 0.99)]; + const errRate = errors / Math.max(lat.length, 1); + record('sustained mixed load: p99 latency and error rate', p99 < 2000 && errRate < 0.05, + `${lat.length} req over ${DURATION_MS}ms @${CONCURRENCY} → p50=${p50.toFixed(1)}ms p99=${p99.toFixed(1)}ms errors=${errors}`); +} + +/* ── Boot + drive ─────────────────────────────────────────────────────── */ + +async function bootServer(): Promise<{ base: string; token: string; stop: () => void }> { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'hostpanel-stress-')); + const port = 3000 + Math.floor(Math.random() * 20_000); + const password = 'stress-harness-password'; + const env = { + ...process.env, + NODE_ENV: 'development', + PORT: String(port), + BIND_HOST: '127.0.0.1', + JWT_SECRET: 'stress-harness-secret', + ADMIN_USER: 'admin', + ADMIN_PASS_HASH: bcrypt.hashSync(password, 4), + DATA_DIR: path.join(tmp, 'data'), + WEBROOT: path.join(tmp, 'www'), + FILES_BASE_DIR: path.join(tmp, 'www'), + VHOST_DIR: path.join(tmp, 'vhost'), + NAMED_DIR: path.join(tmp, 'named'), + BACKUP_DIR: path.join(tmp, 'backups'), + }; + for (const dir of [env.DATA_DIR, env.WEBROOT, env.VHOST_DIR, env.NAMED_DIR, env.BACKUP_DIR]) { + await fs.mkdir(dir, { recursive: true }); + } + + const entry = path.join(__dirname, '..', 'index.js'); + const child: ChildProcess = spawn(process.execPath, [entry], { env, stdio: 'ignore' }); + const base = `http://127.0.0.1:${port}`; + await waitForHealth(base); + + const login = await fetch(`${base}/api/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password }), + }); + const { token } = await login.json() as { token: string }; + if (!token) throw new Error('Stress harness could not authenticate'); + + return { + base, token, + stop: () => { try { child.kill('SIGTERM'); } catch { /* already gone */ } void fs.rm(tmp, { recursive: true, force: true }); }, + }; +} + +async function main() { + let base = process.env.BASE || ''; + let token = process.env.ADMIN_TOKEN || ''; + let stop = () => {}; + + if (!base) { + const booted = await bootServer(); + ({ base, token, stop } = booted); + console.log(`Booted stress target at ${base}\n`); + } else { + await waitForHealth(base); + } + + const scenarios: [string, () => Promise][] = [ + ['globalRateLimit', () => globalRateLimit(base, token)], + ['loginFlood', () => loginFlood(base)], + ['oversizedBody', () => oversizedBody(base, token)], + ['deepJson', () => deepJson(base, token)], + ['hugeQuery', () => hugeQuery(base, token)], + ['halfOpenSockets', () => halfOpenSockets(base)], + ['heavyLimiter', () => heavyLimiter(base, token)], + ['sqliteConcurrency', () => sqliteConcurrency(base, token)], + ['sustainedLoad', () => sustainedLoad(base)], + ]; + + for (const [name, fn] of scenarios) { + bucket++; + try { await fn(); } catch (e: any) { record(name, false, `threw: ${e?.message || e}`); } + await new Promise(r => setTimeout(r, 300)); + } + + stop(); + + const failed = results.filter(r => !r.ok); + console.log(`\n${'='.repeat(70)}`); + console.log(`${results.length - failed.length}/${results.length} scenarios passed`); + if (failed.length) { + console.log('\nFAILED:'); + for (const f of failed) console.log(` - ${f.name}: ${f.detail}`); + process.exit(1); + } +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/server/src/terminal.ts b/server/src/terminal.ts index 7b24dab..79abdde 100644 --- a/server/src/terminal.ts +++ b/server/src/terminal.ts @@ -43,7 +43,13 @@ function auditTerminal(username: string, ip: string, details: Record` so we can distinguish it from any other - // subprotocol entry. Legacy clients that still send ?token=… in the URL - // query string fall back to the URL path during the rollout window. + // the token as `hp-token.`. Subprotocol only — a ?token=… query param + // would land in access logs. const protoHdr = (req.headers['sec-websocket-protocol'] as string | undefined) || ''; const protoEntries = protoHdr.split(',').map(s => s.trim()).filter(Boolean); - const protoToken = protoEntries.find(p => p.startsWith('hp-token.'))?.slice('hp-token.'.length); - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); - const queryToken = url.searchParams.get('token'); - const token = protoToken || queryToken; + const token = protoEntries.find(p => p.startsWith('hp-token.'))?.slice('hp-token.'.length); if (!token) { ws.close(4001, 'Unauthorized: no token'); @@ -89,6 +91,11 @@ export function setupTerminal(httpServer: HttpServer): void { return; } + if (activeSessions >= MAX_TERMINAL_SESSIONS) { + ws.close(4029, 'Too many concurrent terminal sessions'); + return; + } + const shell = selectTerminalShell(process.env.SHELL); const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.socket.remoteAddress || ''; auditTerminal(payload?.username || 'anonymous', ip, { role: payload?.role, shell }); @@ -116,29 +123,42 @@ export function setupTerminal(httpServer: HttpServer): void { return; } + activeSessions++; + let released = false; + const release = () => { if (!released) { released = true; activeSessions--; } }; + ptyProcess.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(data); }); ptyProcess.onExit(() => { + release(); if (ws.readyState === WebSocket.OPEN) ws.close(1000, 'Shell exited'); }); ws.on('message', raw => { + const text = raw.toString(); + if (text.length > MAX_TERMINAL_INPUT_BYTES) return; try { - const msg = JSON.parse(raw.toString()); - if (msg.type === 'input') ptyProcess.write(msg.data); - if (msg.type === 'resize') ptyProcess.resize(Math.max(1, msg.cols), Math.max(1, msg.rows)); + const msg = JSON.parse(text); + if (msg.type === 'input' && typeof msg.data === 'string') ptyProcess.write(msg.data); + if (msg.type === 'resize') { + const cols = Math.min(1000, Math.max(1, Number(msg.cols) || 80)); + const rows = Math.min(1000, Math.max(1, Number(msg.rows) || 24)); + ptyProcess.resize(cols, rows); + } } catch { - ptyProcess.write(raw.toString()); + ptyProcess.write(text); } }); ws.on('close', () => { + release(); try { ptyProcess.kill(); } catch {} }); ws.on('error', () => { + release(); try { ptyProcess.kill(); } catch {} }); }); diff --git a/server/src/utils/reseller-scope.ts b/server/src/utils/reseller-scope.ts new file mode 100644 index 0000000..be01632 --- /dev/null +++ b/server/src/utils/reseller-scope.ts @@ -0,0 +1,43 @@ +import { Request } from 'express'; +import db from '../db'; + +/** + * Reseller tenancy boundary, keyed on accounts.reseller_id. + * + * enforceResellerPrivilege (feature-lists.ts) answers whether a reseller may + * use a feature; these helpers answer which accounts they may use it on. + */ + +export function resellerIdForUsername(username: string | undefined): number | null { + if (!username) return null; + const row = db.prepare( + 'SELECT r.id FROM resellers r JOIN admin_users u ON u.id = r.admin_user_id WHERE u.username = ?' + ).get(username) as { id: number } | undefined; + return row?.id ?? null; +} + +/** + * The reseller id a request is confined to, or null when unrestricted + * (superadmin / admin). A reseller-role token with no resellers row gets -1, + * a scope matching no account, so it fails closed. + */ +export function requestResellerScope(req: Request): number | null { + const user = (req as any).user as { username?: string; role?: string } | undefined; + if (user?.role !== 'reseller') return null; + return resellerIdForUsername(user.username) ?? -1; +} + +/** `AND a.reseller_id = ?` fragment + params for list queries. */ +export function scopeClause(req: Request, column = 'a.reseller_id'): { sql: string; params: number[] } { + const scope = requestResellerScope(req); + return scope === null ? { sql: '', params: [] } : { sql: ` AND ${column} = ?`, params: [scope] }; +} + +/** True when the caller may act on this account. Answer failures with 404 so + * the endpoint doesn't confirm an out-of-scope id exists. */ +export function accountInScope(req: Request, accountId: number | string): boolean { + const scope = requestResellerScope(req); + if (scope === null) return true; + const row = db.prepare('SELECT 1 FROM accounts WHERE id = ? AND reseller_id = ?').get(accountId, scope); + return !!row; +}