chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-03-01 20:44:55 -06:00
parent 023587c48c
commit 1645896e54
20 changed files with 1916 additions and 168 deletions

245
api/package-lock.json generated
View File

@@ -791,9 +791,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -805,9 +805,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -819,9 +819,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -833,9 +833,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -847,9 +847,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -861,9 +861,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -875,9 +875,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -889,9 +889,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -903,9 +903,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -917,9 +917,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -931,9 +931,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", "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": [ "cpu": [
"loong64" "loong64"
], ],
@@ -945,9 +959,23 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", "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": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -959,9 +987,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -973,9 +1001,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -987,9 +1015,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1001,9 +1029,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1015,9 +1043,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1028,10 +1056,24 @@
"linux" "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": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1043,9 +1085,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1057,9 +1099,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1071,9 +1113,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1085,9 +1127,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1283,9 +1325,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -1386,9 +1428,9 @@
} }
}, },
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "4.12.2", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cac": { "node_modules/cac": {
@@ -2464,9 +2506,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -2539,9 +2581,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.2", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2555,28 +2597,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.2", "@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.53.2", "@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.53.2", "@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.53.2", "@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.53.2", "@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.53.2", "@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.53.2", "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.53.2", "@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.53.2", "@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.53.2", "@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.53.2", "@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.53.2", "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.53.2", "@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.53.2", "@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.53.2", "@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.53.2", "@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.53.2", "@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.53.2", "@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.53.2", "@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" "fsevents": "~2.3.2"
} }
}, },

View File

@@ -1,7 +1,7 @@
// prisma/schema.prisma // prisma/schema.prisma
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"] binaryTargets = ["native", "debian-openssl-3.0.x", "windows"]
} }
datasource db { datasource db {

View File

@@ -8,6 +8,33 @@ const BoolFromEnv = z
return normalized === "true" || normalized === "1"; 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({ const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080), 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_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
JWT_SECRET: z.string().min(32), 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_SECRET: z.string().min(32),
COOKIE_DOMAIN: z.string().optional(), COOKIE_DOMAIN: z.string().optional(),
AUTH_DISABLED: BoolFromEnv.optional().default(false), AUTH_DISABLED: BoolFromEnv.optional().default(false),
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), 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"), APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0), UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"), 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_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS, RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me", 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_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
AUTH_DISABLED: process.env.AUTH_DISABLED, 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, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES, 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, APP_ORIGIN: process.env.APP_ORIGIN,
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION, UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE, UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
@@ -92,6 +129,26 @@ if (parsed.NODE_ENV === "production") {
if (!parsed.APP_ORIGIN) { if (!parsed.APP_ORIGIN) {
throw new Error("APP_ORIGIN must be set in production."); 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; export const env = parsed;

View File

@@ -17,7 +17,6 @@ export type AppConfig = typeof env;
const openPaths = new Set([ const openPaths = new Set([
"/health", "/health",
"/health/db",
"/auth/login", "/auth/login",
"/auth/register", "/auth/register",
"/auth/verify", "/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 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_COOKIE = "csrf";
const CSRF_HEADER = "x-csrf-token"; const CSRF_HEADER = "x-csrf-token";
const HASH_OPTIONS: argon2.Options & { raw?: false } = { const HASH_OPTIONS: argon2.Options & { raw?: false } = {
@@ -58,6 +99,7 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
trustProxy: config.NODE_ENV === "production",
requestIdHeader: "x-request-id", requestIdHeader: "x-request-id",
genReqId: (req) => { genReqId: (req) => {
const hdr = req.headers["x-request-id"]; 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 EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds 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 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) => { const toPlainEmailAddress = (value?: string | null) => {
if (!value) return undefined; if (!value) return undefined;
@@ -118,7 +187,7 @@ const mailer = config.SMTP_HOST
host: config.SMTP_HOST, host: config.SMTP_HOST,
port: Number(config.SMTP_PORT ?? 587), port: Number(config.SMTP_PORT ?? 587),
secure: false, secure: false,
requireTLS: true, requireTLS: config.SMTP_REQUIRE_TLS,
tls: { tls: {
rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED, rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED,
}, },
@@ -128,8 +197,8 @@ const mailer = config.SMTP_HOST
pass: config.SMTP_PASS, pass: config.SMTP_PASS,
} }
: undefined, : undefined,
logger: true, logger: !isProd,
debug: true, debug: !isProd,
}) })
: null; : null;
@@ -162,9 +231,8 @@ async function sendEmail({
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`); const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
if (!mailer) { if (!mailer) {
// Dev fallback: log the email for manual copy // Avoid exposing one-time codes in logs when SMTP is unavailable.
app.log.info({ to, subject }, "[email] mailer disabled; logged email content"); app.log.warn({ to, subject }, "[email] mailer disabled; email not sent");
console.log("[email]", { to, subject, text: finalText });
return; return;
} }
@@ -226,12 +294,79 @@ async function issueEmailToken(
return { code, expiresAt }; 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") { async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
await app.prisma.emailToken.deleteMany({ await app.prisma.emailToken.deleteMany({
where: type ? { userId, type } : { userId }, 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 * Calculate the next due date based on frequency for rollover
*/ */
@@ -514,7 +649,6 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
origin: (() => { origin: (() => {
// Keep local/dev friction-free. // Keep local/dev friction-free.
if (config.NODE_ENV !== "production") return true; if (config.NODE_ENV !== "production") return true;
if (configuredOrigins.length === 0) return true;
return (origin, cb) => { return (origin, cb) => {
if (!origin) return cb(null, true); if (!origin) return cb(null, true);
const normalized = normalizeOrigin(origin); const normalized = normalizeOrigin(origin);
@@ -528,17 +662,21 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
max: config.RATE_LIMIT_MAX, max: config.RATE_LIMIT_MAX,
timeWindow: config.RATE_LIMIT_WINDOW_MS, timeWindow: config.RATE_LIMIT_WINDOW_MS,
hook: "onRequest", 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(fastifyCookie, { secret: config.COOKIE_SECRET });
await app.register(fastifyJwt, { await app.register(fastifyJwt, {
secret: config.JWT_SECRET, secret: config.JWT_SECRET,
cookie: { cookieName: "session", signed: false }, cookie: { cookieName: "session", signed: false },
verify: {
algorithms: ["HS256"],
allowedIss: config.JWT_ISSUER,
allowedAud: config.JWT_AUDIENCE,
},
sign: { sign: {
algorithm: "HS256",
iss: config.JWT_ISSUER,
aud: config.JWT_AUDIENCE,
expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`, expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`,
}, },
}); });
@@ -584,6 +722,7 @@ app.decorate("ensureUser", async (userId: string) => {
if (config.AUTH_DISABLED) { if (config.AUTH_DISABLED) {
const userIdHeader = req.headers["x-user-id"]?.toString().trim(); const userIdHeader = req.headers["x-user-id"]?.toString().trim();
if (!userIdHeader) { if (!userIdHeader) {
logSecurityEvent(req, "auth.missing_user_id_header", "failure");
return reply.code(401).send({ error: "No user ID provided" }); return reply.code(401).send({ error: "No user ID provided" });
} }
req.userId = userIdHeader; req.userId = userIdHeader;
@@ -595,6 +734,10 @@ app.decorate("ensureUser", async (userId: string) => {
req.userId = sub; req.userId = sub;
await app.ensureUser(req.userId); await app.ensureUser(req.userId);
} catch { } catch {
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
path,
method: req.method,
});
return reply return reply
.code(401) .code(401)
.send({ .send({
@@ -618,13 +761,19 @@ app.decorate("ensureUser", async (userId: string) => {
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined; const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined;
if (!cookieToken || !headerToken || cookieToken !== headerToken) { if (!cookieToken || !headerToken || cookieToken !== headerToken) {
logSecurityEvent(req, "csrf.validation", "failure", { path });
return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" }); 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(), 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({ const VerifyBody = z.object({
@@ -640,24 +789,21 @@ const AllocationOverrideSchema = z.object({
app.post( app.post(
"/auth/register", "/auth/register",
{ authRateLimit,
config: {
rateLimit: {
max: 10,
timeWindow: 60_000,
},
},
},
async (req, reply) => { 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" }); if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
const { email, password } = parsed.data; const { email, password } = parsed.data;
const normalizedEmail = email.toLowerCase(); const normalizedEmail = normalizeEmail(email);
const existing = await app.prisma.user.findUnique({ const existing = await app.prisma.user.findUnique({
where: { email: normalizedEmail }, where: { email: normalizedEmail },
select: { id: true }, select: { id: true },
}); });
if (existing) { if (existing) {
logSecurityEvent(req, "auth.register", "blocked", {
reason: "email_in_use",
emailFingerprint: fingerprintEmail(normalizedEmail),
});
return reply return reply
.code(409) .code(409)
.send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" }); .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.`, 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>`, 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 }; return { ok: true, needsVerification: true };
}); });
app.post( app.post(
"/auth/login", "/auth/login",
{ authRateLimit,
config: {
rateLimit: {
max: 10,
timeWindow: 60_000,
},
},
},
async (req, reply) => { 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" }); if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
const { email, password } = parsed.data; const { email, password } = parsed.data;
const user = await app.prisma.user.findUnique({ const normalizedEmail = normalizeEmail(email);
where: { email: email.toLowerCase() }, 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,
}); });
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); 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: normalizedEmail },
});
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); 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) { 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" }); return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" });
} }
await app.ensureUser(user.id); await app.ensureUser(user.id);
@@ -721,10 +927,14 @@ app.post(
...(cookieDomain ? { domain: cookieDomain } : {}), ...(cookieDomain ? { domain: cookieDomain } : {}),
}); });
ensureCsrfCookie(reply); ensureCsrfCookie(reply);
logSecurityEvent(req, "auth.login", "success", {
userId: user.id,
emailFingerprint: fingerprintEmail(normalizedEmail),
});
return { ok: true }; return { ok: true };
}); });
app.post("/auth/logout", async (_req, reply) => { app.post("/auth/logout", async (req, reply) => {
reply.clearCookie("session", { reply.clearCookie("session", {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -732,17 +942,22 @@ app.post("/auth/logout", async (_req, reply) => {
secure: config.NODE_ENV === "production", secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}), ...(cookieDomain ? { domain: cookieDomain } : {}),
}); });
logSecurityEvent(req, "auth.logout", "success", { userId: req.userId });
return { ok: true }; return { ok: true };
}); });
app.post("/auth/verify", async (req, reply) => { app.post("/auth/verify", codeVerificationRateLimit, async (req, reply) => {
const parsed = VerifyBody.safeParse(req.body); const parsed = VerifyBody.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" }); 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 } }); const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) { 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" }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
} }
const tokenHash = hashToken(parsed.data.code.trim()); 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 }, where: { userId: user.id, type: "signup", tokenHash },
}); });
if (!token) { 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" }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
} }
if (token.expiresAt < new Date()) { if (token.expiresAt < new Date()) {
await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } }); 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" }); return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
} }
await app.prisma.user.update({ await app.prisma.user.update({
@@ -772,18 +997,51 @@ app.post("/auth/verify", async (req, reply) => {
...(cookieDomain ? { domain: cookieDomain } : {}), ...(cookieDomain ? { domain: cookieDomain } : {}),
}); });
ensureCsrfCookie(reply); ensureCsrfCookie(reply);
logSecurityEvent(req, "auth.verify", "success", {
userId: user.id,
emailFingerprint: fingerprintEmail(normalizedEmail),
});
return { ok: true }; 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); const parsed = z.object({ email: z.string().email() }).safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" }); 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 } }); const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) return reply.code(200).send({ ok: true }); if (!user) {
if (user.emailVerified) return { ok: true, alreadyVerified: true }; 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"); await clearEmailTokens(user.id, "signup");
const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS);
const origin = normalizeOrigin(config.APP_ORIGIN); 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.`, 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>`, 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 }; return { ok: true };
}); });
app.post("/account/delete-request", async (req, reply) => { app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
const Body = z.object({ const Body = z.object({
password: z.string().min(1), password: z.string().min(1),
}); });
@@ -809,12 +1071,37 @@ app.post("/account/delete-request", async (req, reply) => {
select: { id: true, email: true, passwordHash: true }, select: { id: true, email: true, passwordHash: true },
}); });
if (!user?.passwordHash) { 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" }); return reply.code(401).send({ ok: false, message: "Invalid credentials" });
} }
const valid = await argon2.verify(user.passwordHash, parsed.data.password); const valid = await argon2.verify(user.passwordHash, parsed.data.password);
if (!valid) { 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" }); 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"); await clearEmailTokens(user.id, "delete");
const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS); const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS);
await sendEmail({ 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.`, 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>`, 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 }; return { ok: true };
}); });
app.post("/account/confirm-delete", async (req, reply) => { app.post("/account/confirm-delete", codeVerificationRateLimit, async (req, reply) => {
const Body = z.object({ const Body = z.object({
email: z.string().email(), email: z.string().email(),
code: z.string().min(4), code: z.string().min(4),
@@ -836,16 +1127,38 @@ app.post("/account/confirm-delete", async (req, reply) => {
if (!parsed.success) { if (!parsed.success) {
return reply.code(400).send({ ok: false, message: "Invalid payload" }); 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 } }); const user = await app.prisma.user.findUnique({ where: { id: req.userId } });
if (!user) { 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" }); 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) { 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" }); return reply.code(401).send({ ok: false, message: "Invalid credentials" });
} }
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password); const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
if (!passwordOk) { 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" }); return reply.code(401).send({ ok: false, message: "Invalid credentials" });
} }
const tokenHash = hashToken(parsed.data.code.trim()); 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 }, where: { userId: user.id, type: "delete", tokenHash },
}); });
if (!token) { 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" }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
} }
if (token.expiresAt < new Date()) { if (token.expiresAt < new Date()) {
await clearEmailTokens(user.id, "delete"); 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" }); return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
} }
await clearEmailTokens(user.id, "delete"); await clearEmailTokens(user.id, "delete");
@@ -868,6 +1191,10 @@ app.post("/account/confirm-delete", async (req, reply) => {
secure: config.NODE_ENV === "production", secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}), ...(cookieDomain ? { domain: cookieDomain } : {}),
}); });
logSecurityEvent(req, "account.confirm_delete", "success", {
userId: user.id,
emailFingerprint: fingerprintEmail(user.email),
});
return { ok: true }; return { ok: true };
}); });
@@ -962,7 +1289,7 @@ app.patch("/me", async (req, reply) => {
app.patch("/me/password", async (req, reply) => { app.patch("/me/password", async (req, reply) => {
const Body = z.object({ const Body = z.object({
currentPassword: z.string().min(1), currentPassword: z.string().min(1),
newPassword: z.string().min(8), newPassword: passwordSchema,
}); });
const parsed = Body.safeParse(req.body); const parsed = Body.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
@@ -1061,6 +1388,9 @@ app.post("/admin/rollover", async (req, reply) => {
if (!config.AUTH_DISABLED) { if (!config.AUTH_DISABLED) {
return reply.code(403).send({ ok: false, message: "Forbidden" }); 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({ const Body = z.object({
asOf: z.string().datetime().optional(), asOf: z.string().datetime().optional(),
dryRun: z.boolean().optional(), dryRun: z.boolean().optional(),
@@ -1077,13 +1407,15 @@ app.post("/admin/rollover", async (req, reply) => {
// ----- Health ----- // ----- Health -----
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
app.get("/health/db", async () => { if (config.NODE_ENV !== "production") {
app.get("/health/db", async () => {
const start = Date.now(); const start = Date.now();
const [{ now }] = const [{ now }] =
await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now"); await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`;
const latencyMs = Date.now() - start; const latencyMs = Date.now() - start;
return { ok: true, nowISO: now.toISOString(), latencyMs }; return { ok: true, nowISO: now.toISOString(), latencyMs };
}); });
}
// ----- Dashboard ----- // ----- Dashboard -----
app.get("/dashboard", async (req) => { app.get("/dashboard", async (req) => {

View 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] } },
});
}
});
});

View 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");
});
});

View File

@@ -2,18 +2,33 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest"; import request from "supertest";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server"; import { buildApp } from "../src/server";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
let app: FastifyInstance; 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 () => { beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true }); app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
await app.ready(); await app.ready();
}); });
afterAll(async () => { afterAll(async () => {
if (app) {
await app.close(); await app.close();
}
await prisma.$disconnect(); await prisma.$disconnect();
}); });
@@ -24,6 +39,26 @@ describe("Auth routes", () => {
expect(res.body.code).toBe("UNAUTHENTICATED"); 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 () => { it("registers a user and grants access via cookie session", async () => {
const agent = request.agent(app.server); const agent = request.agent(app.server);
const email = `reg-${Date.now()}@test.dev`; const email = `reg-${Date.now()}@test.dev`;
@@ -31,9 +66,10 @@ describe("Auth routes", () => {
const register = await agent.post("/auth/register").send({ email, password }); const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(200); expect(register.status).toBe(200);
expect(register.body.needsVerification).toBe(true);
const dash = await agent.get("/dashboard"); 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 created = await prisma.user.findUniqueOrThrow({ where: { email } });
const [catCount, planCount] = await Promise.all([ const [catCount, planCount] = await Promise.all([
@@ -52,7 +88,10 @@ describe("Auth routes", () => {
const password = "SupersAFE123!"; const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password }); 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 }); const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200); expect(login.status).toBe(200);
@@ -69,15 +108,54 @@ describe("Auth routes", () => {
const password = "SupersAFE123!"; const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password }); 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"); const session = await agent.get("/auth/session");
expect(session.status).toBe(200); expect(session.status).toBe(200);
expect(session.body.userId).toBeDefined(); 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"); const afterLogout = await agent.get("/dashboard");
expect(afterLogout.status).toBe(401); expect(afterLogout.status).toBe(401);
await prisma.user.deleteMany({ where: { email } }); 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 } });
});
}); });

View 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);
});
});

View 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();
}
});
});

View 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 } });
});
});

View 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 });
}
});
});

View 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 } });
});
});

View 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");
});
});

View 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"');
});
});

View 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([]);
});
});

View File

@@ -1,16 +1,49 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, afterAll } from "vitest"; import { beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client"; 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.NODE_ENV = process.env.NODE_ENV || "test";
process.env.DATABASE_URL = process.env.DATABASE_URL = resolveDatabaseUrl();
process.env.DATABASE_URL || process.env.PORT = process.env.PORT || "8081";
"postgres://app:app@localhost:5432/skymoney";
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
process.env.HOST ??= "127.0.0.1"; process.env.HOST ??= "127.0.0.1";
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || ""; process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1"; process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1";
process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "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(); export const prisma = new PrismaClient();
@@ -25,8 +58,14 @@ export async function resetUser(userId: string) {
} }
beforeAll(async () => { beforeAll(async () => {
// make sure the schema is applied before running tests // 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" }); 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 // Ensure a clean slate: wipe all tables to avoid cross-file leakage
await prisma.$transaction([ await prisma.$transaction([

View 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 });
}
});
});

View 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+/);
});
});

View File

@@ -4,6 +4,7 @@ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["tests/**/*.test.ts"], include: ["tests/**/*.test.ts"],
exclude: ["tests/security-misconfiguration.test.ts"],
// run single-threaded to keep DB tests deterministic // run single-threaded to keep DB tests deterministic
pool: "threads", pool: "threads",
poolOptions: { threads: { singleThread: true } }, poolOptions: { threads: { singleThread: true } },
@@ -11,7 +12,6 @@ export default defineConfig({
env: { env: {
NODE_ENV: "test", NODE_ENV: "test",
AUTH_DISABLED: "1", AUTH_DISABLED: "1",
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
SEED_DEFAULT_BUDGET: "1" SEED_DEFAULT_BUDGET: "1"
}, },
setupFiles: ['tests/setup.ts'], setupFiles: ['tests/setup.ts'],

View 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",
},
},
});