chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
245
api/package-lock.json
generated
245
api/package-lock.json
generated
@@ -791,9 +791,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
|
||||
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -805,9 +805,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -819,9 +819,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -833,9 +833,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -847,9 +847,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -861,9 +861,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -875,9 +875,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -889,9 +889,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -903,9 +903,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -917,9 +917,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -931,9 +931,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -945,9 +959,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -959,9 +987,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -973,9 +1001,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -987,9 +1015,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1001,9 +1029,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1015,9 +1043,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1028,10 +1056,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1043,9 +1085,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1057,9 +1099,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1071,9 +1113,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1085,9 +1127,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1283,9 +1325,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -1386,9 +1428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cac": {
|
||||
@@ -2464,9 +2506,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -2539,9 +2581,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
|
||||
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2555,28 +2597,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.2",
|
||||
"@rollup/rollup-android-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-x64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// prisma/schema.prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x", "windows"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
||||
@@ -8,6 +8,33 @@ const BoolFromEnv = z
|
||||
return normalized === "true" || normalized === "1";
|
||||
});
|
||||
|
||||
function isLoopbackOrPrivateHostname(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (!normalized) return true;
|
||||
if (
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "0.0.0.0" ||
|
||||
normalized.endsWith(".local")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (normalized.startsWith("127.")) return true;
|
||||
if (normalized.startsWith("10.")) return true;
|
||||
if (normalized.startsWith("192.168.")) return true;
|
||||
if (normalized.startsWith("169.254.")) return true;
|
||||
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
|
||||
const octets = parts.map((part) => Number(part));
|
||||
if (octets.some((n) => n < 0 || n > 255)) return true;
|
||||
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const Env = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(8080),
|
||||
@@ -18,11 +45,16 @@ const Env = z.object({
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
|
||||
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_ISSUER: z.string().min(1).default("skymoney-api"),
|
||||
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
|
||||
COOKIE_SECRET: z.string().min(32),
|
||||
COOKIE_DOMAIN: z.string().optional(),
|
||||
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
||||
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
||||
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
||||
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
|
||||
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
||||
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
|
||||
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
|
||||
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
|
||||
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
|
||||
@@ -51,11 +83,16 @@ const rawEnv = {
|
||||
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
|
||||
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
|
||||
JWT_ISSUER: process.env.JWT_ISSUER,
|
||||
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
|
||||
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
||||
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
|
||||
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
||||
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
||||
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
||||
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
|
||||
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
|
||||
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
|
||||
APP_ORIGIN: process.env.APP_ORIGIN,
|
||||
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
|
||||
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
|
||||
@@ -92,6 +129,26 @@ if (parsed.NODE_ENV === "production") {
|
||||
if (!parsed.APP_ORIGIN) {
|
||||
throw new Error("APP_ORIGIN must be set in production.");
|
||||
}
|
||||
if (!parsed.APP_ORIGIN.startsWith("https://")) {
|
||||
throw new Error("APP_ORIGIN must use https:// in production.");
|
||||
}
|
||||
let appOriginUrl: URL;
|
||||
try {
|
||||
appOriginUrl = new URL(parsed.APP_ORIGIN);
|
||||
} catch {
|
||||
throw new Error("APP_ORIGIN must be a valid URL in production.");
|
||||
}
|
||||
if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) {
|
||||
throw new Error(
|
||||
"APP_ORIGIN must not point to localhost or private network hosts in production."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) {
|
||||
throw new Error(
|
||||
"AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments."
|
||||
);
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
|
||||
@@ -17,7 +17,6 @@ export type AppConfig = typeof env;
|
||||
|
||||
const openPaths = new Set([
|
||||
"/health",
|
||||
"/health/db",
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/auth/verify",
|
||||
@@ -31,7 +30,49 @@ const mutationRateLimit = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const authRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const codeVerificationRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 8,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const codeIssueRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const pathOf = (url: string) => (url.split("?")[0] || "/");
|
||||
const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase();
|
||||
const isInternalClientIp = (ip: string) => {
|
||||
const normalized = normalizeClientIp(ip);
|
||||
if (normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost") {
|
||||
return true;
|
||||
}
|
||||
if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) {
|
||||
return true;
|
||||
}
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length === 4 && parts[0] === "172") {
|
||||
const second = Number(parts[1]);
|
||||
if (Number.isFinite(second) && second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const CSRF_COOKIE = "csrf";
|
||||
const CSRF_HEADER = "x-csrf-token";
|
||||
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
|
||||
@@ -58,6 +99,7 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
trustProxy: config.NODE_ENV === "production",
|
||||
requestIdHeader: "x-request-id",
|
||||
genReqId: (req) => {
|
||||
const hdr = req.headers["x-request-id"];
|
||||
@@ -103,8 +145,35 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
|
||||
const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(PASSWORD_MIN_LENGTH)
|
||||
.max(128)
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a symbol");
|
||||
|
||||
const normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||
const fingerprintEmail = (email: string) =>
|
||||
createHash("sha256").update(normalizeEmail(email)).digest("hex").slice(0, 16);
|
||||
const logSecurityEvent = (
|
||||
req: { id?: string; ip?: string; headers?: Record<string, any>; log: FastifyInstance["log"] },
|
||||
event: string,
|
||||
outcome: "success" | "failure" | "blocked",
|
||||
details?: Record<string, unknown>
|
||||
) => {
|
||||
req.log.warn({
|
||||
securityEvent: event,
|
||||
outcome,
|
||||
requestId: String(req.id ?? ""),
|
||||
ip: (req.ip || "").replace("::ffff:", ""),
|
||||
userAgent: req.headers?.["user-agent"],
|
||||
...details,
|
||||
});
|
||||
};
|
||||
|
||||
const toPlainEmailAddress = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
@@ -118,7 +187,7 @@ const mailer = config.SMTP_HOST
|
||||
host: config.SMTP_HOST,
|
||||
port: Number(config.SMTP_PORT ?? 587),
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
requireTLS: config.SMTP_REQUIRE_TLS,
|
||||
tls: {
|
||||
rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED,
|
||||
},
|
||||
@@ -128,8 +197,8 @@ const mailer = config.SMTP_HOST
|
||||
pass: config.SMTP_PASS,
|
||||
}
|
||||
: undefined,
|
||||
logger: true,
|
||||
debug: true,
|
||||
logger: !isProd,
|
||||
debug: !isProd,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -162,9 +231,8 @@ async function sendEmail({
|
||||
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
|
||||
|
||||
if (!mailer) {
|
||||
// Dev fallback: log the email for manual copy
|
||||
app.log.info({ to, subject }, "[email] mailer disabled; logged email content");
|
||||
console.log("[email]", { to, subject, text: finalText });
|
||||
// Avoid exposing one-time codes in logs when SMTP is unavailable.
|
||||
app.log.warn({ to, subject }, "[email] mailer disabled; email not sent");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,12 +294,79 @@ async function issueEmailToken(
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
async function assertEmailTokenCooldown(
|
||||
userId: string,
|
||||
type: "signup" | "delete",
|
||||
cooldownMs: number
|
||||
) {
|
||||
if (cooldownMs <= 0) return;
|
||||
const recent = await app.prisma.emailToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
createdAt: { gte: new Date(Date.now() - cooldownMs) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
if (!recent) return;
|
||||
const elapsedMs = Date.now() - recent.createdAt.getTime();
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((cooldownMs - elapsedMs) / 1000));
|
||||
const err: any = new Error("Please wait before requesting another code.");
|
||||
err.statusCode = 429;
|
||||
err.code = "EMAIL_TOKEN_COOLDOWN";
|
||||
err.retryAfterSeconds = retryAfterSeconds;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
|
||||
await app.prisma.emailToken.deleteMany({
|
||||
where: type ? { userId, type } : { userId },
|
||||
});
|
||||
}
|
||||
|
||||
type LoginAttemptState = {
|
||||
failedAttempts: number;
|
||||
lockedUntilMs: number;
|
||||
};
|
||||
|
||||
const loginAttemptStateByEmail = new Map<string, LoginAttemptState>();
|
||||
|
||||
const getLoginLockout = (email: string) => {
|
||||
const key = normalizeEmail(email);
|
||||
const state = loginAttemptStateByEmail.get(key);
|
||||
if (!state) return { locked: false as const };
|
||||
if (state.lockedUntilMs > Date.now()) {
|
||||
const retryAfterSeconds = Math.max(
|
||||
1,
|
||||
Math.ceil((state.lockedUntilMs - Date.now()) / 1000)
|
||||
);
|
||||
return { locked: true as const, retryAfterSeconds };
|
||||
}
|
||||
if (state.lockedUntilMs > 0) {
|
||||
loginAttemptStateByEmail.delete(key);
|
||||
}
|
||||
return { locked: false as const };
|
||||
};
|
||||
|
||||
const registerFailedLoginAttempt = (email: string) => {
|
||||
const key = normalizeEmail(email);
|
||||
const now = Date.now();
|
||||
const current = loginAttemptStateByEmail.get(key);
|
||||
const failedAttempts = (current?.failedAttempts ?? 0) + 1;
|
||||
if (failedAttempts >= config.AUTH_MAX_FAILED_ATTEMPTS) {
|
||||
const lockedUntilMs = now + config.AUTH_LOCKOUT_WINDOW_MS;
|
||||
loginAttemptStateByEmail.set(key, { failedAttempts: 0, lockedUntilMs });
|
||||
return { locked: true as const, retryAfterSeconds: Math.ceil(config.AUTH_LOCKOUT_WINDOW_MS / 1000) };
|
||||
}
|
||||
loginAttemptStateByEmail.set(key, { failedAttempts, lockedUntilMs: 0 });
|
||||
return { locked: false as const };
|
||||
};
|
||||
|
||||
const clearFailedLoginAttempts = (email: string) => {
|
||||
loginAttemptStateByEmail.delete(normalizeEmail(email));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the next due date based on frequency for rollover
|
||||
*/
|
||||
@@ -514,7 +649,6 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
|
||||
origin: (() => {
|
||||
// Keep local/dev friction-free.
|
||||
if (config.NODE_ENV !== "production") return true;
|
||||
if (configuredOrigins.length === 0) return true;
|
||||
return (origin, cb) => {
|
||||
if (!origin) return cb(null, true);
|
||||
const normalized = normalizeOrigin(origin);
|
||||
@@ -528,17 +662,21 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
|
||||
max: config.RATE_LIMIT_MAX,
|
||||
timeWindow: config.RATE_LIMIT_WINDOW_MS,
|
||||
hook: "onRequest",
|
||||
allowList: (req) => {
|
||||
const ip = (req.ip || "").replace("::ffff:", "");
|
||||
return ip === "127.0.0.1" || ip === "::1";
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(fastifyCookie, { secret: config.COOKIE_SECRET });
|
||||
await app.register(fastifyJwt, {
|
||||
secret: config.JWT_SECRET,
|
||||
cookie: { cookieName: "session", signed: false },
|
||||
verify: {
|
||||
algorithms: ["HS256"],
|
||||
allowedIss: config.JWT_ISSUER,
|
||||
allowedAud: config.JWT_AUDIENCE,
|
||||
},
|
||||
sign: {
|
||||
algorithm: "HS256",
|
||||
iss: config.JWT_ISSUER,
|
||||
aud: config.JWT_AUDIENCE,
|
||||
expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`,
|
||||
},
|
||||
});
|
||||
@@ -584,6 +722,7 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
if (config.AUTH_DISABLED) {
|
||||
const userIdHeader = req.headers["x-user-id"]?.toString().trim();
|
||||
if (!userIdHeader) {
|
||||
logSecurityEvent(req, "auth.missing_user_id_header", "failure");
|
||||
return reply.code(401).send({ error: "No user ID provided" });
|
||||
}
|
||||
req.userId = userIdHeader;
|
||||
@@ -595,6 +734,10 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
req.userId = sub;
|
||||
await app.ensureUser(req.userId);
|
||||
} catch {
|
||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||
path,
|
||||
method: req.method,
|
||||
});
|
||||
return reply
|
||||
.code(401)
|
||||
.send({
|
||||
@@ -618,13 +761,19 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
|
||||
const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined;
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
logSecurityEvent(req, "csrf.validation", "failure", { path });
|
||||
return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" });
|
||||
}
|
||||
});
|
||||
|
||||
const AuthBody = z.object({
|
||||
const RegisterBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
const LoginBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
const VerifyBody = z.object({
|
||||
@@ -640,24 +789,21 @@ const AllocationOverrideSchema = z.object({
|
||||
|
||||
app.post(
|
||||
"/auth/register",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
authRateLimit,
|
||||
async (req, reply) => {
|
||||
const parsed = AuthBody.safeParse(req.body);
|
||||
const parsed = RegisterBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
const { email, password } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const existing = await app.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
logSecurityEvent(req, "auth.register", "blocked", {
|
||||
reason: "email_in_use",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" });
|
||||
@@ -683,30 +829,90 @@ app.post(
|
||||
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
|
||||
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "auth.register", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true, needsVerification: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/auth/login",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
authRateLimit,
|
||||
async (req, reply) => {
|
||||
const parsed = AuthBody.safeParse(req.body);
|
||||
const parsed = LoginBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
const { email, password } = parsed.data;
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const lockout = getLoginLockout(normalizedEmail);
|
||||
if (lockout.locked) {
|
||||
reply.header("Retry-After", String(lockout.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: lockout.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
if (!user?.passwordHash) {
|
||||
const failed = registerFailedLoginAttempt(normalizedEmail);
|
||||
if (failed.locked) {
|
||||
reply.header("Retry-After", String(failed.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: failed.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
logSecurityEvent(req, "auth.login", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
if (!valid) {
|
||||
const failed = registerFailedLoginAttempt(normalizedEmail);
|
||||
if (failed.locked) {
|
||||
reply.header("Retry-After", String(failed.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: failed.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
logSecurityEvent(req, "auth.login", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
clearFailedLoginAttempts(normalizedEmail);
|
||||
if (!user.emailVerified) {
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "email_not_verified",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" });
|
||||
}
|
||||
await app.ensureUser(user.id);
|
||||
@@ -721,10 +927,14 @@ app.post(
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
ensureCsrfCookie(reply);
|
||||
logSecurityEvent(req, "auth.login", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_req, reply) => {
|
||||
app.post("/auth/logout", async (req, reply) => {
|
||||
reply.clearCookie("session", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -732,17 +942,22 @@ app.post("/auth/logout", async (_req, reply) => {
|
||||
secure: config.NODE_ENV === "production",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
logSecurityEvent(req, "auth.logout", "success", { userId: req.userId });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/verify", async (req, reply) => {
|
||||
app.post("/auth/verify", codeVerificationRateLimit, async (req, reply) => {
|
||||
const parsed = VerifyBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "invalid_code",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
const tokenHash = hashToken(parsed.data.code.trim());
|
||||
@@ -750,10 +965,20 @@ app.post("/auth/verify", async (req, reply) => {
|
||||
where: { userId: user.id, type: "signup", tokenHash },
|
||||
});
|
||||
if (!token) {
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "invalid_code",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (token.expiresAt < new Date()) {
|
||||
await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } });
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "code_expired",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
|
||||
}
|
||||
await app.prisma.user.update({
|
||||
@@ -772,18 +997,51 @@ app.post("/auth/verify", async (req, reply) => {
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
ensureCsrfCookie(reply);
|
||||
logSecurityEvent(req, "auth.verify", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/verify/resend", async (req, reply) => {
|
||||
app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
||||
const parsed = z.object({ email: z.string().email() }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (!user) return reply.code(200).send({ ok: true });
|
||||
if (user.emailVerified) return { ok: true, alreadyVerified: true };
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "auth.verify_resend", "failure", {
|
||||
reason: "unknown_email",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(200).send({ ok: true });
|
||||
}
|
||||
if (user.emailVerified) {
|
||||
logSecurityEvent(req, "auth.verify_resend", "blocked", {
|
||||
reason: "already_verified",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true, alreadyVerified: true };
|
||||
}
|
||||
try {
|
||||
await assertEmailTokenCooldown(user.id, "signup", EMAIL_TOKEN_COOLDOWN_MS);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||
if (typeof err.retryAfterSeconds === "number") {
|
||||
reply.header("Retry-After", String(err.retryAfterSeconds));
|
||||
}
|
||||
logSecurityEvent(req, "auth.verify_resend", "blocked", {
|
||||
reason: "email_token_cooldown",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await clearEmailTokens(user.id, "signup");
|
||||
const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS);
|
||||
const origin = normalizeOrigin(config.APP_ORIGIN);
|
||||
@@ -793,10 +1051,14 @@ app.post("/auth/verify/resend", async (req, reply) => {
|
||||
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
|
||||
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "auth.verify_resend", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/account/delete-request", async (req, reply) => {
|
||||
app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
@@ -809,12 +1071,37 @@ app.post("/account/delete-request", async (req, reply) => {
|
||||
select: { id: true, email: true, passwordHash: true },
|
||||
});
|
||||
if (!user?.passwordHash) {
|
||||
logSecurityEvent(req, "account.delete_request", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: req.userId,
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const valid = await argon2.verify(user.passwordHash, parsed.data.password);
|
||||
if (!valid) {
|
||||
logSecurityEvent(req, "account.delete_request", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
try {
|
||||
await assertEmailTokenCooldown(user.id, "delete", EMAIL_TOKEN_COOLDOWN_MS);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||
if (typeof err.retryAfterSeconds === "number") {
|
||||
reply.header("Retry-After", String(err.retryAfterSeconds));
|
||||
}
|
||||
logSecurityEvent(req, "account.delete_request", "blocked", {
|
||||
reason: "email_token_cooldown",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS);
|
||||
await sendEmail({
|
||||
@@ -823,10 +1110,14 @@ app.post("/account/delete-request", async (req, reply) => {
|
||||
text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`,
|
||||
html: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "account.delete_request", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/account/confirm-delete", async (req, reply) => {
|
||||
app.post("/account/confirm-delete", codeVerificationRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string().min(4),
|
||||
@@ -836,16 +1127,38 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { id: req.userId } });
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "user_not_found",
|
||||
userId: req.userId,
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (user.email.toLowerCase() !== normalizedEmail) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "blocked", {
|
||||
reason: "email_mismatch",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
if (!user.passwordHash) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
|
||||
if (!passwordOk) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const tokenHash = hashToken(parsed.data.code.trim());
|
||||
@@ -853,10 +1166,20 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
where: { userId: user.id, type: "delete", tokenHash },
|
||||
});
|
||||
if (!token) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_code",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (token.expiresAt < new Date()) {
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "code_expired",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
|
||||
}
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
@@ -868,6 +1191,10 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
secure: config.NODE_ENV === "production",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
logSecurityEvent(req, "account.confirm_delete", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -962,7 +1289,7 @@ app.patch("/me", async (req, reply) => {
|
||||
app.patch("/me/password", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -1061,6 +1388,9 @@ app.post("/admin/rollover", async (req, reply) => {
|
||||
if (!config.AUTH_DISABLED) {
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
if (!isInternalClientIp(req.ip || "")) {
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
const Body = z.object({
|
||||
asOf: z.string().datetime().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
@@ -1077,13 +1407,15 @@ app.post("/admin/rollover", async (req, reply) => {
|
||||
|
||||
// ----- Health -----
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] =
|
||||
await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
if (config.NODE_ENV !== "production") {
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] =
|
||||
await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`;
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Dashboard -----
|
||||
app.get("/dashboard", async (req) => {
|
||||
|
||||
83
api/tests/access-control.account-delete.test.ts
Normal file
83
api/tests/access-control.account-delete.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Account delete access control", () => {
|
||||
it("rejects confirm-delete when payload email targets another user", async () => {
|
||||
const attackerEmail = `attacker-${Date.now()}@test.dev`;
|
||||
const victimEmail = `victim-${Date.now()}@test.dev`;
|
||||
const victimPassword = "VictimPass123!";
|
||||
const deleteCode = "654321";
|
||||
|
||||
const [attacker, victim] = await Promise.all([
|
||||
prisma.user.create({
|
||||
data: {
|
||||
email: attackerEmail,
|
||||
passwordHash: await argon2.hash("AttackerPass123!"),
|
||||
emailVerified: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.create({
|
||||
data: {
|
||||
email: victimEmail,
|
||||
passwordHash: await argon2.hash(victimPassword),
|
||||
emailVerified: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
try {
|
||||
await prisma.emailToken.create({
|
||||
data: {
|
||||
userId: victim.id,
|
||||
type: "delete",
|
||||
tokenHash: createHash("sha256").update(deleteCode).digest("hex"),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
const csrf = randomUUID().replace(/-/g, "");
|
||||
const res = await request(app.server)
|
||||
.post("/account/confirm-delete")
|
||||
.set("x-user-id", attacker.id)
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", `csrf=${csrf}`)
|
||||
.send({
|
||||
email: victim.email,
|
||||
code: deleteCode,
|
||||
password: victimPassword,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const victimStillExists = await prisma.user.findUnique({
|
||||
where: { id: victim.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(victimStillExists?.id).toBe(victim.id);
|
||||
} finally {
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [attacker.id, victim.id] } },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
113
api/tests/access-control.admin-rollover.test.ts
Normal file
113
api/tests/access-control.admin-rollover.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let authEnabledApp: FastifyInstance;
|
||||
let authDisabledApp: FastifyInstance;
|
||||
|
||||
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
for (const raw of setCookie) {
|
||||
const firstPart = raw.split(";")[0] ?? "";
|
||||
const [name, value] = firstPart.split("=");
|
||||
if (name?.trim() === cookieName && value) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
authEnabledApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
|
||||
await authEnabledApp.ready();
|
||||
|
||||
authDisabledApp = await buildApp({
|
||||
NODE_ENV: "production",
|
||||
AUTH_DISABLED: true,
|
||||
ALLOW_INSECURE_AUTH_FOR_DEV: true,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
CORS_ORIGINS: "https://allowed.example.com",
|
||||
APP_ORIGIN: "https://allowed.example.com",
|
||||
});
|
||||
await authDisabledApp.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (authEnabledApp) await authEnabledApp.close();
|
||||
if (authDisabledApp) await authDisabledApp.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("/admin/rollover access control", () => {
|
||||
it("returns 401 without a valid authenticated session when AUTH_DISABLED=false", async () => {
|
||||
const res = await request(authEnabledApp.server)
|
||||
.post("/admin/rollover")
|
||||
.send({ dryRun: true });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("returns 403 for authenticated users when AUTH_DISABLED=false", async () => {
|
||||
const agent = request.agent(authEnabledApp.server);
|
||||
const email = `rollover-auth-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await argon2.hash(password),
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const login = await agent.post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
|
||||
expect(csrf).toBeTruthy();
|
||||
|
||||
const res = await agent
|
||||
.post("/admin/rollover")
|
||||
.set("x-csrf-token", csrf as string)
|
||||
.send({ dryRun: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.ok).toBe(false);
|
||||
} finally {
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 403 for non-internal client IP when AUTH_DISABLED=true", async () => {
|
||||
const csrf = randomUUID().replace(/-/g, "");
|
||||
const res = await request(authDisabledApp.server)
|
||||
.post("/admin/rollover")
|
||||
.set("x-user-id", `external-${Date.now()}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", `csrf=${csrf}`)
|
||||
.set("x-forwarded-for", "8.8.8.8")
|
||||
.send({ dryRun: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("allows internal client IP when AUTH_DISABLED=true", async () => {
|
||||
const csrf = randomUUID().replace(/-/g, "");
|
||||
const res = await request(authDisabledApp.server)
|
||||
.post("/admin/rollover")
|
||||
.set("x-user-id", `internal-${Date.now()}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", `csrf=${csrf}`)
|
||||
.set("x-forwarded-for", "10.23.45.67")
|
||||
.send({ dryRun: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.dryRun).toBe(true);
|
||||
expect(typeof res.body.processed).toBe("number");
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,33 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
for (const raw of setCookie) {
|
||||
const firstPart = raw.split(";")[0] ?? "";
|
||||
const [name, value] = firstPart.split("=");
|
||||
if (name?.trim() === cookieName && value) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -24,6 +39,26 @@ describe("Auth routes", () => {
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("rejects spoofed x-user-id when auth is enabled", async () => {
|
||||
const res = await request(app.server)
|
||||
.get("/dashboard")
|
||||
.set("x-user-id", "spoofed-user-id");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("rejects weak passwords on registration", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `weak-${Date.now()}@test.dev`;
|
||||
const password = "weakpass123";
|
||||
const register = await agent.post("/auth/register").send({ email, password });
|
||||
expect(register.status).toBe(400);
|
||||
expect(register.body.ok).toBe(false);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it("registers a user and grants access via cookie session", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `reg-${Date.now()}@test.dev`;
|
||||
@@ -31,9 +66,10 @@ describe("Auth routes", () => {
|
||||
|
||||
const register = await agent.post("/auth/register").send({ email, password });
|
||||
expect(register.status).toBe(200);
|
||||
expect(register.body.needsVerification).toBe(true);
|
||||
|
||||
const dash = await agent.get("/dashboard");
|
||||
expect(dash.status).toBe(200);
|
||||
expect(dash.status).toBe(401);
|
||||
|
||||
const created = await prisma.user.findUniqueOrThrow({ where: { email } });
|
||||
const [catCount, planCount] = await Promise.all([
|
||||
@@ -52,7 +88,10 @@ describe("Auth routes", () => {
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await agent.post("/auth/register").send({ email, password });
|
||||
await agent.post("/auth/logout");
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
|
||||
const login = await agent.post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
@@ -69,15 +108,54 @@ describe("Auth routes", () => {
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await agent.post("/auth/register").send({ email, password });
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
const login = await agent.post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
|
||||
expect(csrf).toBeTruthy();
|
||||
|
||||
const session = await agent.get("/auth/session");
|
||||
expect(session.status).toBe(200);
|
||||
expect(session.body.userId).toBeDefined();
|
||||
|
||||
await agent.post("/auth/logout");
|
||||
await agent
|
||||
.post("/auth/logout")
|
||||
.set("x-csrf-token", csrf as string);
|
||||
const afterLogout = await agent.get("/dashboard");
|
||||
expect(afterLogout.status).toBe(401);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("locks login after repeated failed attempts", async () => {
|
||||
const agent = request.agent(app.server);
|
||||
const email = `locked-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await argon2.hash(password),
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= 4; attempt++) {
|
||||
const res = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
|
||||
const locked = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
|
||||
expect(locked.status).toBe(429);
|
||||
expect(locked.body.code).toBe("LOGIN_LOCKED");
|
||||
expect(locked.headers["retry-after"]).toBeTruthy();
|
||||
|
||||
const blockedValid = await agent.post("/auth/login").send({ email, password });
|
||||
expect(blockedValid.status).toBe(429);
|
||||
expect(blockedValid.body.code).toBe("LOGIN_LOCKED");
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
145
api/tests/cryptographic-failures.runtime.test.ts
Normal file
145
api/tests/cryptographic-failures.runtime.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
|
||||
const COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
|
||||
let app: FastifyInstance;
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
return Buffer.from(value)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function signHs256Token(payload: Record<string, unknown>, secret: string): string {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(signingInput)
|
||||
.digest("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({
|
||||
NODE_ENV: "test",
|
||||
AUTH_DISABLED: false,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
JWT_SECRET,
|
||||
COOKIE_SECRET,
|
||||
JWT_ISSUER: "skymoney-api",
|
||||
JWT_AUDIENCE: "skymoney-web",
|
||||
APP_ORIGIN: "http://localhost:5173",
|
||||
});
|
||||
await app.ready();
|
||||
// Keep JWT verification behavior under test without requiring DB connectivity.
|
||||
(app as any).ensureUser = async () => undefined;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("A04 Cryptographic Failures (runtime adversarial checks)", () => {
|
||||
const csrf = "runtime-test-csrf-token";
|
||||
|
||||
it("rejects token with wrong issuer", async () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const token = signHs256Token(
|
||||
{
|
||||
sub: `wrong-iss-${Date.now()}`,
|
||||
iss: "attacker-issuer",
|
||||
aud: "skymoney-web",
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 600,
|
||||
},
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await request(app.server)
|
||||
.post("/auth/refresh")
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("rejects token with wrong audience", async () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const token = signHs256Token(
|
||||
{
|
||||
sub: `wrong-aud-${Date.now()}`,
|
||||
iss: "skymoney-api",
|
||||
aud: "attacker-app",
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 600,
|
||||
},
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await request(app.server)
|
||||
.post("/auth/refresh")
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("rejects token with alg=none (unsigned token)", async () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
|
||||
const encodedPayload = base64UrlEncode(
|
||||
JSON.stringify({
|
||||
sub: `none-alg-${Date.now()}`,
|
||||
iss: "skymoney-api",
|
||||
aud: "skymoney-web",
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 600,
|
||||
})
|
||||
);
|
||||
const token = `${encodedHeader}.${encodedPayload}.`;
|
||||
|
||||
const res = await request(app.server)
|
||||
.post("/auth/refresh")
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe("UNAUTHENTICATED");
|
||||
});
|
||||
|
||||
it("accepts token with correct signature, issuer, and audience", async () => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const token = signHs256Token(
|
||||
{
|
||||
sub: `valid-${Date.now()}`,
|
||||
iss: "skymoney-api",
|
||||
aud: "skymoney-web",
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 600,
|
||||
},
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await request(app.server)
|
||||
.post("/auth/refresh")
|
||||
.set("x-csrf-token", csrf)
|
||||
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
83
api/tests/cryptographic-failures.test.ts
Normal file
83
api/tests/cryptographic-failures.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
function setRequiredBaseEnv() {
|
||||
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
|
||||
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
|
||||
process.env.CORS_ORIGINS = "https://allowed.example.com";
|
||||
process.env.AUTH_DISABLED = "0";
|
||||
process.env.SEED_DEFAULT_BUDGET = "0";
|
||||
}
|
||||
|
||||
describe("A04 Cryptographic Failures", () => {
|
||||
it("rejects non-https APP_ORIGIN in production", async () => {
|
||||
setRequiredBaseEnv();
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.APP_ORIGIN = "http://allowed.example.com";
|
||||
|
||||
await expect(import("../src/env")).rejects.toThrow(
|
||||
"APP_ORIGIN must use https:// in production."
|
||||
);
|
||||
});
|
||||
|
||||
it("applies secure JWT defaults for issuer and audience", async () => {
|
||||
setRequiredBaseEnv();
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.APP_ORIGIN = "https://allowed.example.com";
|
||||
delete process.env.JWT_ISSUER;
|
||||
delete process.env.JWT_AUDIENCE;
|
||||
|
||||
const envModule = await import("../src/env");
|
||||
expect(envModule.env.JWT_ISSUER).toBe("skymoney-api");
|
||||
expect(envModule.env.JWT_AUDIENCE).toBe("skymoney-web");
|
||||
});
|
||||
|
||||
it("registers fastify-jwt with explicit algorithm and claim validation", async () => {
|
||||
const jwtPlugin = vi.fn(async () => undefined);
|
||||
vi.doMock("@fastify/jwt", () => ({
|
||||
default: jwtPlugin,
|
||||
}));
|
||||
|
||||
const { buildApp } = await import("../src/server");
|
||||
const app = await buildApp({
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef",
|
||||
COOKIE_SECRET: "test-cookie-secret-32-chars-abcdef",
|
||||
AUTH_DISABLED: true,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
APP_ORIGIN: "http://localhost:5173",
|
||||
});
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
expect(jwtPlugin.mock.calls.length).toBeGreaterThan(0);
|
||||
const jwtCall = jwtPlugin.mock.calls.find((call) => {
|
||||
const opts = call[1] as Record<string, any> | undefined;
|
||||
return !!opts?.sign && !!opts?.verify;
|
||||
});
|
||||
expect(jwtCall).toBeTruthy();
|
||||
const jwtOptions = jwtCall?.[1] as Record<string, any>;
|
||||
expect(jwtOptions.sign.algorithm).toBe("HS256");
|
||||
expect(jwtOptions.sign.iss).toBe("skymoney-api");
|
||||
expect(jwtOptions.sign.aud).toBe("skymoney-web");
|
||||
expect(jwtOptions.verify.algorithms).toEqual(["HS256"]);
|
||||
expect(jwtOptions.verify.allowedIss).toBe("skymoney-api");
|
||||
expect(jwtOptions.verify.allowedAud).toBe("skymoney-web");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
94
api/tests/identification-auth-failures.test.ts
Normal file
94
api/tests/identification-auth-failures.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
for (const raw of setCookie) {
|
||||
const firstPart = raw.split(";")[0] ?? "";
|
||||
const [name, value] = firstPart.split("=");
|
||||
if (name?.trim() === cookieName && value) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({
|
||||
AUTH_DISABLED: false,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
AUTH_MAX_FAILED_ATTEMPTS: 3,
|
||||
AUTH_LOCKOUT_WINDOW_MS: 120_000,
|
||||
});
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("A07 Identification and Authentication Failures", () => {
|
||||
it("rejects weak passwords on registration and password updates", async () => {
|
||||
const regEmail = `weak-reg-${Date.now()}@test.dev`;
|
||||
const weakRegister = await request(app.server)
|
||||
.post("/auth/register")
|
||||
.send({ email: regEmail, password: "weakpass123" });
|
||||
expect(weakRegister.status).toBe(400);
|
||||
|
||||
const email = `pw-update-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
await prisma.user.create({
|
||||
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
|
||||
});
|
||||
const login = await request(app.server).post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
|
||||
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
|
||||
expect(csrf).toBeTruthy();
|
||||
expect(sessionCookie).toBeTruthy();
|
||||
|
||||
const weakUpdate = await request(app.server)
|
||||
.patch("/me/password")
|
||||
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
|
||||
.set("x-csrf-token", csrf as string)
|
||||
.send({ currentPassword: password, newPassword: "weakpass123" });
|
||||
expect(weakUpdate.status).toBe(400);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("locks login according to configured threshold/window", async () => {
|
||||
const email = `lockout-threshold-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
await prisma.user.create({
|
||||
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const fail = await request(app.server).post("/auth/login").send({
|
||||
email,
|
||||
password: "WrongPass123!",
|
||||
});
|
||||
expect(fail.status).toBe(401);
|
||||
}
|
||||
|
||||
const locked = await request(app.server).post("/auth/login").send({
|
||||
email,
|
||||
password: "WrongPass123!",
|
||||
});
|
||||
expect(locked.status).toBe(429);
|
||||
expect(locked.body.code).toBe("LOGIN_LOCKED");
|
||||
expect(locked.headers["retry-after"]).toBeTruthy();
|
||||
|
||||
const blockedValid = await request(app.server).post("/auth/login").send({ email, password });
|
||||
expect(blockedValid.status).toBe(429);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
});
|
||||
99
api/tests/injection-safety.test.ts
Normal file
99
api/tests/injection-safety.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("A05 Injection Safety", () => {
|
||||
function runNormalizedScript(scriptPath: string, env: Record<string, string>) {
|
||||
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a05-script-"));
|
||||
const normalizedScript = join(tmpScriptDir, "script.sh");
|
||||
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
|
||||
chmodSync(normalizedScript, 0o755);
|
||||
const res = spawnSync("bash", [normalizedScript], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
rmSync(tmpScriptDir, { recursive: true, force: true });
|
||||
return res;
|
||||
}
|
||||
|
||||
it("contains no unsafe Prisma raw SQL APIs across API source", () => {
|
||||
const apiRoot = resolve(__dirname, "..");
|
||||
const badPatterns = ["$queryRawUnsafe(", "$executeRawUnsafe("];
|
||||
const queue = [resolve(apiRoot, "src")];
|
||||
const offenders: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop() as string;
|
||||
for (const name of readdirSync(current)) {
|
||||
const full = join(current, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
queue.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!full.endsWith(".ts")) continue;
|
||||
const content = readFileSync(full, "utf8");
|
||||
if (badPatterns.some((p) => content.includes(p))) offenders.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects malicious restore DB identifiers before DB command execution", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a05-restore-"));
|
||||
const fakeBin = join(tmpRoot, "bin");
|
||||
const backupFile = join(tmpRoot, "sample.dump");
|
||||
const checksumFile = `${backupFile}.sha256`;
|
||||
const markerFile = join(tmpRoot, "db_called.marker");
|
||||
|
||||
try {
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
writeFileSync(backupFile, "valid-content");
|
||||
const backupBytes = readFileSync(backupFile);
|
||||
const hash = createHash("sha256").update(backupBytes).digest("hex");
|
||||
writeFileSync(checksumFile, `${hash} sample.dump\n`);
|
||||
|
||||
const fakePsql = join(fakeBin, "psql");
|
||||
const fakePgRestore = join(fakeBin, "pg_restore");
|
||||
const fakeSha256sum = join(fakeBin, "sha256sum");
|
||||
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
|
||||
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
|
||||
writeFileSync(
|
||||
fakeSha256sum,
|
||||
`#!/usr/bin/env bash\nset -euo pipefail\necho "${hash} $1"\n`
|
||||
);
|
||||
chmodSync(fakePsql, 0o755);
|
||||
chmodSync(fakePgRestore, 0o755);
|
||||
chmodSync(fakeSha256sum, 0o755);
|
||||
|
||||
const res = runNormalizedScript(restoreScriptPath, {
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
BACKUP_FILE: backupFile,
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
|
||||
RESTORE_DB: 'bad-name"; DROP DATABASE skymoney; --',
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(0);
|
||||
expect(`${res.stdout}${res.stderr}`).toContain("RESTORE_DB must match");
|
||||
expect(existsSync(markerFile)).toBe(false);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
104
api/tests/insecure-design.test.ts
Normal file
104
api/tests/insecure-design.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import argon2 from "argon2";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let app: FastifyInstance;
|
||||
|
||||
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
|
||||
if (!setCookie) return null;
|
||||
for (const raw of setCookie) {
|
||||
const firstPart = raw.split(";")[0] ?? "";
|
||||
const [name, value] = firstPart.split("=");
|
||||
if (name?.trim() === cookieName && value) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("A06 Insecure Design", () => {
|
||||
it("enforces resend-code cooldown with 429 and Retry-After", async () => {
|
||||
const email = `cooldown-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
|
||||
await request(app.server).post("/auth/register").send({ email, password });
|
||||
|
||||
// Registration issues a signup token; immediate resend should be cooldown-blocked.
|
||||
const resend = await request(app.server).post("/auth/verify/resend").send({ email });
|
||||
expect(resend.status).toBe(429);
|
||||
expect(resend.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
|
||||
expect(resend.headers["retry-after"]).toBeTruthy();
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("enforces delete-code cooldown with 429 and Retry-After", async () => {
|
||||
const email = `delete-cooldown-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await argon2.hash(password),
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
const login = await request(app.server).post("/auth/login").send({ email, password });
|
||||
expect(login.status).toBe(200);
|
||||
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
|
||||
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
|
||||
expect(csrf).toBeTruthy();
|
||||
expect(sessionCookie).toBeTruthy();
|
||||
|
||||
const first = await request(app.server)
|
||||
.post("/account/delete-request")
|
||||
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
|
||||
.set("x-csrf-token", csrf as string)
|
||||
.send({ password });
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const second = await request(app.server)
|
||||
.post("/account/delete-request")
|
||||
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
|
||||
.set("x-csrf-token", csrf as string)
|
||||
.send({ password });
|
||||
expect(second.status).toBe(429);
|
||||
expect(second.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
|
||||
expect(second.headers["retry-after"]).toBeTruthy();
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
|
||||
it("rate-limits repeated invalid verification attempts", async () => {
|
||||
const email = `verify-rate-${Date.now()}@test.dev`;
|
||||
const password = "SupersAFE123!";
|
||||
await request(app.server).post("/auth/register").send({ email, password });
|
||||
|
||||
let limited = false;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const res = await request(app.server)
|
||||
.post("/auth/verify")
|
||||
.send({ email, code: randomUUID().slice(0, 6) });
|
||||
if (res.status === 429) {
|
||||
limited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(limited).toBe(true);
|
||||
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
});
|
||||
});
|
||||
77
api/tests/security-logging-monitoring-failures.test.ts
Normal file
77
api/tests/security-logging-monitoring-failures.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/server";
|
||||
|
||||
let authApp: FastifyInstance;
|
||||
let csrfApp: FastifyInstance;
|
||||
const capturedEvents: Array<Record<string, unknown>> = [];
|
||||
|
||||
function attachSecurityEventCapture(app: FastifyInstance) {
|
||||
const logger = app.log as any;
|
||||
const originalChild = logger.child.bind(logger);
|
||||
logger.child = (...args: any[]) => {
|
||||
const child = originalChild(...args);
|
||||
const originalWarn = child.warn.bind(child);
|
||||
child.warn = (obj: unknown, ...rest: unknown[]) => {
|
||||
if (obj && typeof obj === "object") {
|
||||
const payload = obj as Record<string, unknown>;
|
||||
if (typeof payload.securityEvent === "string") capturedEvents.push(payload);
|
||||
}
|
||||
return originalWarn(obj, ...rest);
|
||||
};
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
authApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
|
||||
await authApp.ready();
|
||||
(authApp as any).ensureUser = async () => undefined;
|
||||
attachSecurityEventCapture(authApp);
|
||||
|
||||
csrfApp = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
|
||||
await csrfApp.ready();
|
||||
(csrfApp as any).ensureUser = async () => undefined;
|
||||
attachSecurityEventCapture(csrfApp);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (authApp) await authApp.close();
|
||||
if (csrfApp) await csrfApp.close();
|
||||
});
|
||||
|
||||
describe("A09 Security Logging and Monitoring Failures", () => {
|
||||
it("emits structured security log for unauthenticated protected-route access", async () => {
|
||||
capturedEvents.length = 0;
|
||||
|
||||
const res = await request(authApp.server).get("/dashboard");
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const event = capturedEvents.find((payload) => {
|
||||
return payload.securityEvent === "auth.unauthenticated_request";
|
||||
});
|
||||
expect(event).toBeTruthy();
|
||||
expect(event?.outcome).toBe("failure");
|
||||
expect(typeof event?.requestId).toBe("string");
|
||||
expect(typeof event?.ip).toBe("string");
|
||||
});
|
||||
|
||||
it("emits structured security log for csrf validation failures", async () => {
|
||||
capturedEvents.length = 0;
|
||||
|
||||
const res = await request(csrfApp.server)
|
||||
.post("/me")
|
||||
.set("x-user-id", `csrf-user-${Date.now()}`)
|
||||
.send({ displayName: "NoCsrf" });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe("CSRF");
|
||||
|
||||
const event = capturedEvents.find((payload) => payload.securityEvent === "csrf.validation");
|
||||
expect(event).toBeTruthy();
|
||||
expect(event?.outcome).toBe("failure");
|
||||
expect(typeof event?.requestId).toBe("string");
|
||||
expect(typeof event?.ip).toBe("string");
|
||||
});
|
||||
});
|
||||
150
api/tests/security-misconfiguration.test.ts
Normal file
150
api/tests/security-misconfiguration.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
vi.doUnmock("nodemailer");
|
||||
});
|
||||
|
||||
describe("A02 Security Misconfiguration", () => {
|
||||
it("enforces CORS allowlist in production", async () => {
|
||||
const { buildApp } = await import("../src/server");
|
||||
const app = await buildApp({
|
||||
NODE_ENV: "production",
|
||||
CORS_ORIGINS: "https://allowed.example.com",
|
||||
AUTH_DISABLED: false,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
APP_ORIGIN: "https://allowed.example.com",
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
try {
|
||||
const allowedOrigin = "https://allowed.example.com";
|
||||
const deniedOrigin = "https://denied.example.com";
|
||||
|
||||
const allowed = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/health",
|
||||
headers: {
|
||||
origin: allowedOrigin,
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
});
|
||||
|
||||
expect(allowed.statusCode).toBe(204);
|
||||
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
|
||||
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
|
||||
|
||||
const denied = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/health",
|
||||
headers: {
|
||||
origin: deniedOrigin,
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
});
|
||||
|
||||
expect([204, 404]).toContain(denied.statusCode);
|
||||
expect(denied.headers["access-control-allow-origin"]).toBeUndefined();
|
||||
expect(denied.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies expected CORS response headers at runtime for allowed origins", async () => {
|
||||
const { buildApp } = await import("../src/server");
|
||||
const app = await buildApp({
|
||||
NODE_ENV: "production",
|
||||
CORS_ORIGINS: "https://allowed.example.com",
|
||||
AUTH_DISABLED: false,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
APP_ORIGIN: "https://allowed.example.com",
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/health",
|
||||
headers: {
|
||||
origin: "https://allowed.example.com",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
expect(response.headers["access-control-allow-origin"]).toBe(
|
||||
"https://allowed.example.com"
|
||||
);
|
||||
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
||||
expect(response.headers.vary).toContain("Origin");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("disables SMTP debug logger in production mailer config", async () => {
|
||||
const verify = vi.fn(async () => undefined);
|
||||
const sendMail = vi.fn(async () => ({ messageId: "mock-message-id" }));
|
||||
const createTransport = vi.fn(() => ({ verify, sendMail }));
|
||||
vi.doMock("nodemailer", () => ({
|
||||
default: { createTransport },
|
||||
}));
|
||||
|
||||
const { buildApp } = await import("../src/server");
|
||||
const app = await buildApp({
|
||||
NODE_ENV: "production",
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: 587,
|
||||
SMTP_REQUIRE_TLS: true,
|
||||
SMTP_TLS_REJECT_UNAUTHORIZED: true,
|
||||
SMTP_USER: "smtp-user",
|
||||
SMTP_PASS: "smtp-pass",
|
||||
CORS_ORIGINS: "https://allowed.example.com",
|
||||
APP_ORIGIN: "https://allowed.example.com",
|
||||
AUTH_DISABLED: false,
|
||||
SEED_DEFAULT_BUDGET: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
expect(createTransport).toHaveBeenCalledTimes(1);
|
||||
const transportOptions = createTransport.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(transportOptions.requireTLS).toBe(true);
|
||||
expect(transportOptions.logger).toBe(false);
|
||||
expect(transportOptions.debug).toBe(false);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("defines anti-framing headers in edge configs", () => {
|
||||
const root = resolve(__dirname, "..", "..");
|
||||
const caddyProd = readFileSync(resolve(root, "Caddyfile.prod"), "utf8");
|
||||
const caddyDev = readFileSync(resolve(root, "Caddyfile.dev"), "utf8");
|
||||
const nginxProd = readFileSync(
|
||||
resolve(root, "deploy/nginx/skymoneybudget.com.conf"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(caddyProd).toContain('X-Frame-Options "DENY"');
|
||||
expect(caddyProd).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
|
||||
expect(caddyDev).toContain('X-Frame-Options "DENY"');
|
||||
expect(caddyDev).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
|
||||
expect(nginxProd).toContain('add_header X-Frame-Options "DENY" always;');
|
||||
expect(nginxProd).toContain(
|
||||
`add_header Content-Security-Policy "frame-ancestors 'none'" always;`
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps docker-compose service ports bound to localhost only", () => {
|
||||
const root = resolve(__dirname, "..", "..");
|
||||
const compose = readFileSync(resolve(root, "docker-compose.yml"), "utf8");
|
||||
|
||||
expect(compose).toContain('- "127.0.0.1:5432:5432"');
|
||||
expect(compose).toContain('- "127.0.0.1:8081:8080"');
|
||||
});
|
||||
});
|
||||
82
api/tests/server-side-request-forgery.test.ts
Normal file
82
api/tests/server-side-request-forgery.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
function setRequiredBaseEnv() {
|
||||
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
|
||||
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
|
||||
process.env.CORS_ORIGINS = "https://allowed.example.com";
|
||||
process.env.AUTH_DISABLED = "0";
|
||||
process.env.SEED_DEFAULT_BUDGET = "0";
|
||||
}
|
||||
|
||||
describe("A10 Server-Side Request Forgery", () => {
|
||||
it("rejects localhost/private APP_ORIGIN values in production", async () => {
|
||||
const blockedOrigins = [
|
||||
"https://127.0.0.1:5173",
|
||||
"https://10.0.0.5:5173",
|
||||
"https://192.168.1.10:5173",
|
||||
"https://172.16.5.20:5173",
|
||||
"https://localhost:5173",
|
||||
"https://app.local:5173",
|
||||
"https://[::1]:5173",
|
||||
];
|
||||
|
||||
for (const origin of blockedOrigins) {
|
||||
setRequiredBaseEnv();
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.APP_ORIGIN = origin;
|
||||
await expect(import("../src/env")).rejects.toThrow(
|
||||
"APP_ORIGIN must not point to localhost or private network hosts in production."
|
||||
);
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts public APP_ORIGIN in production", async () => {
|
||||
setRequiredBaseEnv();
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.APP_ORIGIN = "https://app.example.com";
|
||||
|
||||
const envModule = await import("../src/env");
|
||||
expect(envModule.env.APP_ORIGIN).toBe("https://app.example.com");
|
||||
});
|
||||
|
||||
it("keeps API source free of generic outbound HTTP request clients", () => {
|
||||
const apiRoot = resolve(__dirname, "..");
|
||||
const srcRoot = resolve(apiRoot, "src");
|
||||
const queue = [srcRoot];
|
||||
const offenders: string[] = [];
|
||||
const patterns = ["fetch(", "axios", "http.request(", "https.request("];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.pop() as string;
|
||||
for (const name of readdirSync(current)) {
|
||||
const full = join(current, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (full.includes(`${join("src", "scripts")}`)) continue;
|
||||
queue.push(full);
|
||||
continue;
|
||||
}
|
||||
if (!full.endsWith(".ts")) continue;
|
||||
const content = readFileSync(full, "utf8");
|
||||
if (patterns.some((p) => content.includes(p))) offenders.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,49 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeAll, afterAll } from "vitest";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
function readEnvValue(filePath: string, key: string): string | undefined {
|
||||
if (!existsSync(filePath)) return undefined;
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const line = content
|
||||
.split(/\r?\n/)
|
||||
.find((raw) => raw.trim().startsWith(`${key}=`));
|
||||
if (!line) return undefined;
|
||||
const value = line.slice(line.indexOf("=") + 1).trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim();
|
||||
if (process.env.BACKUP_DATABASE_URL?.trim()) return process.env.BACKUP_DATABASE_URL.trim();
|
||||
if (process.env.DATABASE_URL?.trim()) return process.env.DATABASE_URL.trim();
|
||||
|
||||
const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")];
|
||||
for (const envPath of envPaths) {
|
||||
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
|
||||
if (testUrl) return testUrl;
|
||||
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
|
||||
if (backupUrl) return backupUrl;
|
||||
const dbUrl = readEnvValue(envPath, "DATABASE_URL");
|
||||
if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:");
|
||||
}
|
||||
|
||||
return "postgres://app:app@127.0.0.1:5432/skymoney";
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || "test";
|
||||
process.env.DATABASE_URL =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://app:app@localhost:5432/skymoney";
|
||||
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
|
||||
process.env.DATABASE_URL = resolveDatabaseUrl();
|
||||
process.env.PORT = process.env.PORT || "8081";
|
||||
process.env.HOST ??= "127.0.0.1";
|
||||
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
|
||||
process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1";
|
||||
process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1";
|
||||
process.env.JWT_SECRET =
|
||||
process.env.JWT_SECRET || "test-jwt-secret-32-chars-min-abcdef";
|
||||
process.env.COOKIE_SECRET =
|
||||
process.env.COOKIE_SECRET || "test-cookie-secret-32-chars-abcdef";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
@@ -25,8 +58,14 @@ export async function resetUser(userId: string) {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// make sure the schema is applied before running tests
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
// Optional schema bootstrap for CI/local environments that can run Prisma CLI.
|
||||
if (process.env.TEST_APPLY_SCHEMA === "1") {
|
||||
try {
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
} catch {
|
||||
execSync("npx prisma db push --skip-generate --accept-data-loss", { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
|
||||
await prisma.$transaction([
|
||||
|
||||
95
api/tests/software-data-integrity-failures.test.ts
Normal file
95
api/tests/software-data-integrity-failures.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function runScript(scriptPath: string, env: Record<string, string>) {
|
||||
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a08-script-"));
|
||||
const normalizedScript = join(tmpScriptDir, "script.sh");
|
||||
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
|
||||
chmodSync(normalizedScript, 0o755);
|
||||
const result = spawnSync("bash", [normalizedScript], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
rmSync(tmpScriptDir, { recursive: true, force: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("A08 Software and Data Integrity Failures", () => {
|
||||
it("creates checksum artifacts during backup execution", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const backupScriptPath = resolve(repoRoot, "scripts/backup.sh");
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-backup-"));
|
||||
const fakeBin = join(tmpRoot, "bin");
|
||||
const outDir = join(tmpRoot, "backups");
|
||||
const fakePgDump = join(fakeBin, "pg_dump");
|
||||
|
||||
try {
|
||||
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
|
||||
writeFileSync(
|
||||
fakePgDump,
|
||||
"#!/usr/bin/env bash\nset -euo pipefail\nout=''\nwhile [[ $# -gt 0 ]]; do\n if [[ \"$1\" == '-f' ]]; then out=\"$2\"; shift 2; continue; fi\n shift\ndone\nprintf 'fake-dump-data' > \"$out\"\n"
|
||||
);
|
||||
chmodSync(fakePgDump, 0o755);
|
||||
|
||||
const res = runScript(backupScriptPath, {
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
BACKUP_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
BACKUP_DIR: outDir,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(0);
|
||||
const names = readdirSync(outDir);
|
||||
expect(names.some((n) => n.endsWith(".dump"))).toBe(true);
|
||||
expect(names.some((n) => n.endsWith(".sha256"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks restore on checksum mismatch before DB commands execute", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-restore-"));
|
||||
const fakeBin = join(tmpRoot, "bin");
|
||||
const backupFile = join(tmpRoot, "sample.dump");
|
||||
const checksumFile = `${backupFile}.sha256`;
|
||||
const markerFile = join(tmpRoot, "db_called.marker");
|
||||
|
||||
try {
|
||||
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
|
||||
writeFileSync(backupFile, "tampered-content");
|
||||
writeFileSync(checksumFile, `${"0".repeat(64)} sample.dump\n`);
|
||||
|
||||
const fakePsql = join(fakeBin, "psql");
|
||||
const fakePgRestore = join(fakeBin, "pg_restore");
|
||||
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
|
||||
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
|
||||
chmodSync(fakePsql, 0o755);
|
||||
chmodSync(fakePgRestore, 0o755);
|
||||
|
||||
const res = runScript(restoreScriptPath, {
|
||||
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
|
||||
BACKUP_FILE: backupFile,
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
|
||||
});
|
||||
|
||||
expect(res.status).not.toBe(0);
|
||||
expect(`${res.stdout}${res.stderr}`).toContain("checksum verification failed");
|
||||
expect(existsSync(markerFile)).toBe(false);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
34
api/tests/software-supply-chain-failures.test.ts
Normal file
34
api/tests/software-supply-chain-failures.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("A03 Software Supply Chain Failures", () => {
|
||||
it("enforces deploy workflow dependency-audit gate for api and web", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const deployWorkflow = readFileSync(
|
||||
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(deployWorkflow).toContain("name: Supply chain checks (production dependencies)");
|
||||
expect(deployWorkflow).toContain("cd api");
|
||||
expect(deployWorkflow).toContain("cd ../web");
|
||||
|
||||
const npmCiMatches = deployWorkflow.match(/\bnpm ci\b/g) ?? [];
|
||||
expect(npmCiMatches.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const auditMatches =
|
||||
deployWorkflow.match(/npm audit --omit=dev --audit-level=high/g) ?? [];
|
||||
expect(auditMatches.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("pins checkout action to an explicit version tag", () => {
|
||||
const repoRoot = resolve(__dirname, "..", "..");
|
||||
const deployWorkflow = readFileSync(
|
||||
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(deployWorkflow).toMatch(/uses:\s*actions\/checkout@v\d+\.\d+\.\d+/);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
exclude: ["tests/security-misconfiguration.test.ts"],
|
||||
// run single-threaded to keep DB tests deterministic
|
||||
pool: "threads",
|
||||
poolOptions: { threads: { singleThread: true } },
|
||||
@@ -11,9 +12,8 @@ export default defineConfig({
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
AUTH_DISABLED: "1",
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
SEED_DEFAULT_BUDGET: "1"
|
||||
},
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
38
api/vitest.security.config.ts
Normal file
38
api/vitest.security.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const dbSecurityTestsEnabled = process.env.SECURITY_DB_TESTS === "1";
|
||||
const baseSecurityTests = [
|
||||
"tests/security-misconfiguration.test.ts",
|
||||
"tests/software-supply-chain-failures.test.ts",
|
||||
"tests/cryptographic-failures.test.ts",
|
||||
"tests/cryptographic-failures.runtime.test.ts",
|
||||
"tests/injection-safety.test.ts",
|
||||
"tests/software-data-integrity-failures.test.ts",
|
||||
"tests/security-logging-monitoring-failures.test.ts",
|
||||
"tests/server-side-request-forgery.test.ts",
|
||||
];
|
||||
const dbSecurityTests = [
|
||||
"tests/insecure-design.test.ts",
|
||||
"tests/identification-auth-failures.test.ts",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: dbSecurityTestsEnabled
|
||||
? [...baseSecurityTests, ...dbSecurityTests]
|
||||
: baseSecurityTests,
|
||||
pool: "threads",
|
||||
poolOptions: { threads: { singleThread: true } },
|
||||
testTimeout: 30_000,
|
||||
setupFiles: dbSecurityTestsEnabled ? ["tests/setup.ts"] : [],
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
||||
AUTH_DISABLED: "1",
|
||||
SEED_DEFAULT_BUDGET: "1",
|
||||
JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef",
|
||||
COOKIE_SECRET: "test-cookie-secret-32-chars-abcdef",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user