From 1645896e5449c54660c9b11dcd3cdcce2d79cec1 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Sun, 1 Mar 2026 20:44:55 -0600 Subject: [PATCH] chore: ran security check for OWASP top 10 --- api/package-lock.json | 245 ++++++---- api/prisma/schema.prisma | 2 +- api/src/env.ts | 57 +++ api/src/server.ts | 442 +++++++++++++++--- .../access-control.account-delete.test.ts | 83 ++++ .../access-control.admin-rollover.test.ts | 113 +++++ api/tests/auth.routes.test.ts | 86 +++- .../cryptographic-failures.runtime.test.ts | 145 ++++++ api/tests/cryptographic-failures.test.ts | 83 ++++ .../identification-auth-failures.test.ts | 94 ++++ api/tests/injection-safety.test.ts | 99 ++++ api/tests/insecure-design.test.ts | 104 +++++ ...curity-logging-monitoring-failures.test.ts | 77 +++ api/tests/security-misconfiguration.test.ts | 150 ++++++ api/tests/server-side-request-forgery.test.ts | 82 ++++ api/tests/setup.ts | 51 +- .../software-data-integrity-failures.test.ts | 95 ++++ .../software-supply-chain-failures.test.ts | 34 ++ api/vitest.config.ts | 4 +- api/vitest.security.config.ts | 38 ++ 20 files changed, 1916 insertions(+), 168 deletions(-) create mode 100644 api/tests/access-control.account-delete.test.ts create mode 100644 api/tests/access-control.admin-rollover.test.ts create mode 100644 api/tests/cryptographic-failures.runtime.test.ts create mode 100644 api/tests/cryptographic-failures.test.ts create mode 100644 api/tests/identification-auth-failures.test.ts create mode 100644 api/tests/injection-safety.test.ts create mode 100644 api/tests/insecure-design.test.ts create mode 100644 api/tests/security-logging-monitoring-failures.test.ts create mode 100644 api/tests/security-misconfiguration.test.ts create mode 100644 api/tests/server-side-request-forgery.test.ts create mode 100644 api/tests/software-data-integrity-failures.test.ts create mode 100644 api/tests/software-supply-chain-failures.test.ts create mode 100644 api/vitest.security.config.ts diff --git a/api/package-lock.json b/api/package-lock.json index 8af6ae0..41d536d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -791,9 +791,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -805,9 +805,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -819,9 +819,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -847,9 +847,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -861,9 +861,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -875,9 +875,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -889,9 +889,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -903,9 +903,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -917,9 +917,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -931,9 +931,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -945,9 +959,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -959,9 +987,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -973,9 +1001,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -987,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1001,9 +1029,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1015,9 +1043,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1028,10 +1056,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1043,9 +1085,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1057,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1071,9 +1113,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1085,9 +1127,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1283,9 +1325,9 @@ "license": "MIT" }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1386,9 +1428,9 @@ } }, "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, "node_modules/cac": { @@ -2464,9 +2506,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2539,9 +2581,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2555,28 +2597,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index cb750ed..56b9f2b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1,7 +1,7 @@ // prisma/schema.prisma generator client { provider = "prisma-client-js" - binaryTargets = ["native", "debian-openssl-3.0.x"] + binaryTargets = ["native", "debian-openssl-3.0.x", "windows"] } datasource db { diff --git a/api/src/env.ts b/api/src/env.ts index 8a88e9e..41eae0f 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -8,6 +8,33 @@ const BoolFromEnv = z return normalized === "true" || normalized === "1"; }); +function isLoopbackOrPrivateHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (!normalized) return true; + if ( + normalized === "localhost" || + normalized === "::1" || + normalized === "0.0.0.0" || + normalized.endsWith(".local") + ) { + return true; + } + if (normalized.startsWith("127.")) return true; + if (normalized.startsWith("10.")) return true; + if (normalized.startsWith("192.168.")) return true; + if (normalized.startsWith("169.254.")) return true; + + const parts = normalized.split("."); + if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) { + const octets = parts.map((part) => Number(part)); + if (octets.some((n) => n < 0 || n > 255)) return true; + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; + return false; + } + + return false; +} + const Env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: z.coerce.number().int().positive().default(8080), @@ -18,11 +45,16 @@ const Env = z.object({ RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), JWT_SECRET: z.string().min(32), + JWT_ISSUER: z.string().min(1).default("skymoney-api"), + JWT_AUDIENCE: z.string().min(1).default("skymoney-web"), COOKIE_SECRET: z.string().min(32), COOKIE_DOMAIN: z.string().optional(), AUTH_DISABLED: BoolFromEnv.optional().default(false), + ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), + AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5), + AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000), APP_ORIGIN: z.string().min(1).default("http://localhost:5173"), UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0), UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"), @@ -51,11 +83,16 @@ const rawEnv = { RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS, JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me", + JWT_ISSUER: process.env.JWT_ISSUER, + JWT_AUDIENCE: process.env.JWT_AUDIENCE, COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me", COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, AUTH_DISABLED: process.env.AUTH_DISABLED, + ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES, + AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS, + AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS, APP_ORIGIN: process.env.APP_ORIGIN, UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION, UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE, @@ -92,6 +129,26 @@ if (parsed.NODE_ENV === "production") { if (!parsed.APP_ORIGIN) { throw new Error("APP_ORIGIN must be set in production."); } + if (!parsed.APP_ORIGIN.startsWith("https://")) { + throw new Error("APP_ORIGIN must use https:// in production."); + } + let appOriginUrl: URL; + try { + appOriginUrl = new URL(parsed.APP_ORIGIN); + } catch { + throw new Error("APP_ORIGIN must be a valid URL in production."); + } + if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) { + throw new Error( + "APP_ORIGIN must not point to localhost or private network hosts in production." + ); + } +} + +if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) { + throw new Error( + "AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments." + ); } export const env = parsed; diff --git a/api/src/server.ts b/api/src/server.ts index eeb924b..5b4a38f 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -17,7 +17,6 @@ export type AppConfig = typeof env; const openPaths = new Set([ "/health", - "/health/db", "/auth/login", "/auth/register", "/auth/verify", @@ -31,7 +30,49 @@ const mutationRateLimit = { }, }, }; +const authRateLimit = { + config: { + rateLimit: { + max: 10, + timeWindow: 60_000, + }, + }, +}; +const codeVerificationRateLimit = { + config: { + rateLimit: { + max: 8, + timeWindow: 60_000, + }, + }, +}; +const codeIssueRateLimit = { + config: { + rateLimit: { + max: 5, + timeWindow: 60_000, + }, + }, +}; const pathOf = (url: string) => (url.split("?")[0] || "/"); +const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase(); +const isInternalClientIp = (ip: string) => { + const normalized = normalizeClientIp(ip); + if (normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost") { + return true; + } + if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) { + return true; + } + const parts = normalized.split("."); + if (parts.length === 4 && parts[0] === "172") { + const second = Number(parts[1]); + if (Number.isFinite(second) && second >= 16 && second <= 31) { + return true; + } + } + return false; +}; const CSRF_COOKIE = "csrf"; const CSRF_HEADER = "x-csrf-token"; const HASH_OPTIONS: argon2.Options & { raw?: false } = { @@ -58,6 +99,7 @@ export async function buildApp(overrides: Partial = {}): Promise { const hdr = req.headers["x-request-id"]; @@ -103,8 +145,35 @@ const ensureCsrfCookie = (reply: any, existing?: string) => { const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds +const PASSWORD_MIN_LENGTH = 12; +const passwordSchema = z + .string() + .min(PASSWORD_MIN_LENGTH) + .max(128) + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a symbol"); const normalizeOrigin = (origin: string) => origin.replace(/\/$/, ""); +const normalizeEmail = (email: string) => email.trim().toLowerCase(); +const fingerprintEmail = (email: string) => + createHash("sha256").update(normalizeEmail(email)).digest("hex").slice(0, 16); +const logSecurityEvent = ( + req: { id?: string; ip?: string; headers?: Record; log: FastifyInstance["log"] }, + event: string, + outcome: "success" | "failure" | "blocked", + details?: Record +) => { + req.log.warn({ + securityEvent: event, + outcome, + requestId: String(req.id ?? ""), + ip: (req.ip || "").replace("::ffff:", ""), + userAgent: req.headers?.["user-agent"], + ...details, + }); +}; const toPlainEmailAddress = (value?: string | null) => { if (!value) return undefined; @@ -118,7 +187,7 @@ const mailer = config.SMTP_HOST host: config.SMTP_HOST, port: Number(config.SMTP_PORT ?? 587), secure: false, - requireTLS: true, + requireTLS: config.SMTP_REQUIRE_TLS, tls: { rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED, }, @@ -128,8 +197,8 @@ const mailer = config.SMTP_HOST pass: config.SMTP_PASS, } : undefined, - logger: true, - debug: true, + logger: !isProd, + debug: !isProd, }) : null; @@ -162,9 +231,8 @@ async function sendEmail({ const finalHtml = buildEmailHtmlBody(html ?? `

${text}

`); if (!mailer) { - // Dev fallback: log the email for manual copy - app.log.info({ to, subject }, "[email] mailer disabled; logged email content"); - console.log("[email]", { to, subject, text: finalText }); + // Avoid exposing one-time codes in logs when SMTP is unavailable. + app.log.warn({ to, subject }, "[email] mailer disabled; email not sent"); return; } @@ -226,12 +294,79 @@ async function issueEmailToken( return { code, expiresAt }; } +async function assertEmailTokenCooldown( + userId: string, + type: "signup" | "delete", + cooldownMs: number +) { + if (cooldownMs <= 0) return; + const recent = await app.prisma.emailToken.findFirst({ + where: { + userId, + type, + createdAt: { gte: new Date(Date.now() - cooldownMs) }, + }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true }, + }); + if (!recent) return; + const elapsedMs = Date.now() - recent.createdAt.getTime(); + const retryAfterSeconds = Math.max(1, Math.ceil((cooldownMs - elapsedMs) / 1000)); + const err: any = new Error("Please wait before requesting another code."); + err.statusCode = 429; + err.code = "EMAIL_TOKEN_COOLDOWN"; + err.retryAfterSeconds = retryAfterSeconds; + throw err; +} + async function clearEmailTokens(userId: string, type?: "signup" | "delete") { await app.prisma.emailToken.deleteMany({ where: type ? { userId, type } : { userId }, }); } +type LoginAttemptState = { + failedAttempts: number; + lockedUntilMs: number; +}; + +const loginAttemptStateByEmail = new Map(); + +const getLoginLockout = (email: string) => { + const key = normalizeEmail(email); + const state = loginAttemptStateByEmail.get(key); + if (!state) return { locked: false as const }; + if (state.lockedUntilMs > Date.now()) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((state.lockedUntilMs - Date.now()) / 1000) + ); + return { locked: true as const, retryAfterSeconds }; + } + if (state.lockedUntilMs > 0) { + loginAttemptStateByEmail.delete(key); + } + return { locked: false as const }; +}; + +const registerFailedLoginAttempt = (email: string) => { + const key = normalizeEmail(email); + const now = Date.now(); + const current = loginAttemptStateByEmail.get(key); + const failedAttempts = (current?.failedAttempts ?? 0) + 1; + if (failedAttempts >= config.AUTH_MAX_FAILED_ATTEMPTS) { + const lockedUntilMs = now + config.AUTH_LOCKOUT_WINDOW_MS; + loginAttemptStateByEmail.set(key, { failedAttempts: 0, lockedUntilMs }); + return { locked: true as const, retryAfterSeconds: Math.ceil(config.AUTH_LOCKOUT_WINDOW_MS / 1000) }; + } + loginAttemptStateByEmail.set(key, { failedAttempts, lockedUntilMs: 0 }); + return { locked: false as const }; +}; + +const clearFailedLoginAttempts = (email: string) => { + loginAttemptStateByEmail.delete(normalizeEmail(email)); +}; + /** * Calculate the next due date based on frequency for rollover */ @@ -514,7 +649,6 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) { origin: (() => { // Keep local/dev friction-free. if (config.NODE_ENV !== "production") return true; - if (configuredOrigins.length === 0) return true; return (origin, cb) => { if (!origin) return cb(null, true); const normalized = normalizeOrigin(origin); @@ -528,17 +662,21 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) { max: config.RATE_LIMIT_MAX, timeWindow: config.RATE_LIMIT_WINDOW_MS, hook: "onRequest", - allowList: (req) => { - const ip = (req.ip || "").replace("::ffff:", ""); - return ip === "127.0.0.1" || ip === "::1"; - }, }); await app.register(fastifyCookie, { secret: config.COOKIE_SECRET }); await app.register(fastifyJwt, { secret: config.JWT_SECRET, cookie: { cookieName: "session", signed: false }, + verify: { + algorithms: ["HS256"], + allowedIss: config.JWT_ISSUER, + allowedAud: config.JWT_AUDIENCE, + }, sign: { + algorithm: "HS256", + iss: config.JWT_ISSUER, + aud: config.JWT_AUDIENCE, expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`, }, }); @@ -584,6 +722,7 @@ app.decorate("ensureUser", async (userId: string) => { if (config.AUTH_DISABLED) { const userIdHeader = req.headers["x-user-id"]?.toString().trim(); if (!userIdHeader) { + logSecurityEvent(req, "auth.missing_user_id_header", "failure"); return reply.code(401).send({ error: "No user ID provided" }); } req.userId = userIdHeader; @@ -595,6 +734,10 @@ app.decorate("ensureUser", async (userId: string) => { req.userId = sub; await app.ensureUser(req.userId); } catch { + logSecurityEvent(req, "auth.unauthenticated_request", "failure", { + path, + method: req.method, + }); return reply .code(401) .send({ @@ -618,13 +761,19 @@ app.decorate("ensureUser", async (userId: string) => { const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined; if (!cookieToken || !headerToken || cookieToken !== headerToken) { + logSecurityEvent(req, "csrf.validation", "failure", { path }); return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" }); } }); -const AuthBody = z.object({ +const RegisterBody = z.object({ email: z.string().email(), - password: z.string().min(8), + password: passwordSchema, +}); + +const LoginBody = z.object({ + email: z.string().email(), + password: z.string().min(1).max(128), }); const VerifyBody = z.object({ @@ -640,24 +789,21 @@ const AllocationOverrideSchema = z.object({ app.post( "/auth/register", - { - config: { - rateLimit: { - max: 10, - timeWindow: 60_000, - }, - }, - }, + authRateLimit, async (req, reply) => { - const parsed = AuthBody.safeParse(req.body); + const parsed = RegisterBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); const { email, password } = parsed.data; - const normalizedEmail = email.toLowerCase(); + const normalizedEmail = normalizeEmail(email); const existing = await app.prisma.user.findUnique({ where: { email: normalizedEmail }, select: { id: true }, }); if (existing) { + logSecurityEvent(req, "auth.register", "blocked", { + reason: "email_in_use", + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return reply .code(409) .send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" }); @@ -683,30 +829,90 @@ app.post( text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, you can also verify at ${origin}/verify.

`, }); + logSecurityEvent(req, "auth.register", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return { ok: true, needsVerification: true }; }); app.post( "/auth/login", - { - config: { - rateLimit: { - max: 10, - timeWindow: 60_000, - }, - }, - }, + authRateLimit, async (req, reply) => { - const parsed = AuthBody.safeParse(req.body); + const parsed = LoginBody.safeParse(req.body); if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" }); const { email, password } = parsed.data; + const normalizedEmail = normalizeEmail(email); + const lockout = getLoginLockout(normalizedEmail); + if (lockout.locked) { + reply.header("Retry-After", String(lockout.retryAfterSeconds)); + logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + emailFingerprint: fingerprintEmail(normalizedEmail), + retryAfterSeconds: lockout.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } const user = await app.prisma.user.findUnique({ - where: { email: email.toLowerCase() }, + where: { email: normalizedEmail }, }); - if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + if (!user?.passwordHash) { + const failed = registerFailedLoginAttempt(normalizedEmail); + if (failed.locked) { + reply.header("Retry-After", String(failed.retryAfterSeconds)); + logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + emailFingerprint: fingerprintEmail(normalizedEmail), + retryAfterSeconds: failed.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } + logSecurityEvent(req, "auth.login", "failure", { + reason: "invalid_credentials", + emailFingerprint: fingerprintEmail(normalizedEmail), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } const valid = await argon2.verify(user.passwordHash, password); - if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + if (!valid) { + const failed = registerFailedLoginAttempt(normalizedEmail); + if (failed.locked) { + reply.header("Retry-After", String(failed.retryAfterSeconds)); + logSecurityEvent(req, "auth.login", "blocked", { + reason: "login_locked", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + retryAfterSeconds: failed.retryAfterSeconds, + }); + return reply.code(429).send({ + ok: false, + code: "LOGIN_LOCKED", + message: "Too many failed login attempts. Please try again later.", + }); + } + logSecurityEvent(req, "auth.login", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + clearFailedLoginAttempts(normalizedEmail); if (!user.emailVerified) { + logSecurityEvent(req, "auth.login", "blocked", { + reason: "email_not_verified", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }); } await app.ensureUser(user.id); @@ -721,10 +927,14 @@ app.post( ...(cookieDomain ? { domain: cookieDomain } : {}), }); ensureCsrfCookie(reply); + logSecurityEvent(req, "auth.login", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return { ok: true }; }); -app.post("/auth/logout", async (_req, reply) => { +app.post("/auth/logout", async (req, reply) => { reply.clearCookie("session", { path: "/", httpOnly: true, @@ -732,17 +942,22 @@ app.post("/auth/logout", async (_req, reply) => { secure: config.NODE_ENV === "production", ...(cookieDomain ? { domain: cookieDomain } : {}), }); + logSecurityEvent(req, "auth.logout", "success", { userId: req.userId }); return { ok: true }; }); -app.post("/auth/verify", async (req, reply) => { +app.post("/auth/verify", codeVerificationRateLimit, async (req, reply) => { const parsed = VerifyBody.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ ok: false, message: "Invalid payload" }); } - const normalizedEmail = parsed.data.email.toLowerCase(); + const normalizedEmail = normalizeEmail(parsed.data.email); const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); if (!user) { + logSecurityEvent(req, "auth.verify", "failure", { + reason: "invalid_code", + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); } const tokenHash = hashToken(parsed.data.code.trim()); @@ -750,10 +965,20 @@ app.post("/auth/verify", async (req, reply) => { where: { userId: user.id, type: "signup", tokenHash }, }); if (!token) { + logSecurityEvent(req, "auth.verify", "failure", { + reason: "invalid_code", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); } if (token.expiresAt < new Date()) { await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } }); + logSecurityEvent(req, "auth.verify", "failure", { + reason: "code_expired", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); } await app.prisma.user.update({ @@ -772,18 +997,51 @@ app.post("/auth/verify", async (req, reply) => { ...(cookieDomain ? { domain: cookieDomain } : {}), }); ensureCsrfCookie(reply); + logSecurityEvent(req, "auth.verify", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return { ok: true }; }); -app.post("/auth/verify/resend", async (req, reply) => { +app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => { const parsed = z.object({ email: z.string().email() }).safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ ok: false, message: "Invalid payload" }); } - const normalizedEmail = parsed.data.email.toLowerCase(); + const normalizedEmail = normalizeEmail(parsed.data.email); const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); - if (!user) return reply.code(200).send({ ok: true }); - if (user.emailVerified) return { ok: true, alreadyVerified: true }; + if (!user) { + logSecurityEvent(req, "auth.verify_resend", "failure", { + reason: "unknown_email", + emailFingerprint: fingerprintEmail(normalizedEmail), + }); + return reply.code(200).send({ ok: true }); + } + if (user.emailVerified) { + logSecurityEvent(req, "auth.verify_resend", "blocked", { + reason: "already_verified", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); + return { ok: true, alreadyVerified: true }; + } + try { + await assertEmailTokenCooldown(user.id, "signup", EMAIL_TOKEN_COOLDOWN_MS); + } catch (err: any) { + if (err?.code === "EMAIL_TOKEN_COOLDOWN") { + if (typeof err.retryAfterSeconds === "number") { + reply.header("Retry-After", String(err.retryAfterSeconds)); + } + logSecurityEvent(req, "auth.verify_resend", "blocked", { + reason: "email_token_cooldown", + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); + return reply.code(429).send({ ok: false, code: err.code, message: err.message }); + } + throw err; + } await clearEmailTokens(user.id, "signup"); const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); const origin = normalizeOrigin(config.APP_ORIGIN); @@ -793,10 +1051,14 @@ app.post("/auth/verify/resend", async (req, reply) => { text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, verify at ${origin}/verify.

`, }); + logSecurityEvent(req, "auth.verify_resend", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(normalizedEmail), + }); return { ok: true }; }); -app.post("/account/delete-request", async (req, reply) => { +app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => { const Body = z.object({ password: z.string().min(1), }); @@ -809,12 +1071,37 @@ app.post("/account/delete-request", async (req, reply) => { select: { id: true, email: true, passwordHash: true }, }); if (!user?.passwordHash) { + logSecurityEvent(req, "account.delete_request", "failure", { + reason: "invalid_credentials", + userId: req.userId, + }); return reply.code(401).send({ ok: false, message: "Invalid credentials" }); } const valid = await argon2.verify(user.passwordHash, parsed.data.password); if (!valid) { + logSecurityEvent(req, "account.delete_request", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return reply.code(401).send({ ok: false, message: "Invalid credentials" }); } + try { + await assertEmailTokenCooldown(user.id, "delete", EMAIL_TOKEN_COOLDOWN_MS); + } catch (err: any) { + if (err?.code === "EMAIL_TOKEN_COOLDOWN") { + if (typeof err.retryAfterSeconds === "number") { + reply.header("Retry-After", String(err.retryAfterSeconds)); + } + logSecurityEvent(req, "account.delete_request", "blocked", { + reason: "email_token_cooldown", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); + return reply.code(429).send({ ok: false, code: err.code, message: err.message }); + } + throw err; + } await clearEmailTokens(user.id, "delete"); const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS); await sendEmail({ @@ -823,10 +1110,14 @@ app.post("/account/delete-request", async (req, reply) => { text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`, html: `

Your SkyMoney delete confirmation code is ${code}.

Enter it in the app to delete your account.

`, }); + logSecurityEvent(req, "account.delete_request", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return { ok: true }; }); -app.post("/account/confirm-delete", async (req, reply) => { +app.post("/account/confirm-delete", codeVerificationRateLimit, async (req, reply) => { const Body = z.object({ email: z.string().email(), code: z.string().min(4), @@ -836,16 +1127,38 @@ app.post("/account/confirm-delete", async (req, reply) => { if (!parsed.success) { return reply.code(400).send({ ok: false, message: "Invalid payload" }); } - const normalizedEmail = parsed.data.email.toLowerCase(); - const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + const normalizedEmail = normalizeEmail(parsed.data.email); + const user = await app.prisma.user.findUnique({ where: { id: req.userId } }); if (!user) { + logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "user_not_found", + userId: req.userId, + }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); } + if (user.email.toLowerCase() !== normalizedEmail) { + logSecurityEvent(req, "account.confirm_delete", "blocked", { + reason: "email_mismatch", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); + return reply.code(403).send({ ok: false, message: "Forbidden" }); + } if (!user.passwordHash) { + logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return reply.code(401).send({ ok: false, message: "Invalid credentials" }); } const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password); if (!passwordOk) { + logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_credentials", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return reply.code(401).send({ ok: false, message: "Invalid credentials" }); } const tokenHash = hashToken(parsed.data.code.trim()); @@ -853,10 +1166,20 @@ app.post("/account/confirm-delete", async (req, reply) => { where: { userId: user.id, type: "delete", tokenHash }, }); if (!token) { + logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "invalid_code", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); } if (token.expiresAt < new Date()) { await clearEmailTokens(user.id, "delete"); + logSecurityEvent(req, "account.confirm_delete", "failure", { + reason: "code_expired", + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); } await clearEmailTokens(user.id, "delete"); @@ -868,6 +1191,10 @@ app.post("/account/confirm-delete", async (req, reply) => { secure: config.NODE_ENV === "production", ...(cookieDomain ? { domain: cookieDomain } : {}), }); + logSecurityEvent(req, "account.confirm_delete", "success", { + userId: user.id, + emailFingerprint: fingerprintEmail(user.email), + }); return { ok: true }; }); @@ -962,7 +1289,7 @@ app.patch("/me", async (req, reply) => { app.patch("/me/password", async (req, reply) => { const Body = z.object({ currentPassword: z.string().min(1), - newPassword: z.string().min(8), + newPassword: passwordSchema, }); const parsed = Body.safeParse(req.body); if (!parsed.success) { @@ -1061,6 +1388,9 @@ app.post("/admin/rollover", async (req, reply) => { if (!config.AUTH_DISABLED) { return reply.code(403).send({ ok: false, message: "Forbidden" }); } + if (!isInternalClientIp(req.ip || "")) { + return reply.code(403).send({ ok: false, message: "Forbidden" }); + } const Body = z.object({ asOf: z.string().datetime().optional(), dryRun: z.boolean().optional(), @@ -1077,13 +1407,15 @@ app.post("/admin/rollover", async (req, reply) => { // ----- Health ----- app.get("/health", async () => ({ ok: true })); -app.get("/health/db", async () => { - const start = Date.now(); - const [{ now }] = - await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now"); - const latencyMs = Date.now() - start; - return { ok: true, nowISO: now.toISOString(), latencyMs }; -}); +if (config.NODE_ENV !== "production") { + app.get("/health/db", async () => { + const start = Date.now(); + const [{ now }] = + await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`; + const latencyMs = Date.now() - start; + return { ok: true, nowISO: now.toISOString(), latencyMs }; + }); +} // ----- Dashboard ----- app.get("/dashboard", async (req) => { diff --git a/api/tests/access-control.account-delete.test.ts b/api/tests/access-control.account-delete.test.ts new file mode 100644 index 0000000..c96c857 --- /dev/null +++ b/api/tests/access-control.account-delete.test.ts @@ -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] } }, + }); + } + }); +}); diff --git a/api/tests/access-control.admin-rollover.test.ts b/api/tests/access-control.admin-rollover.test.ts new file mode 100644 index 0000000..983603a --- /dev/null +++ b/api/tests/access-control.admin-rollover.test.ts @@ -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"); + }); +}); diff --git a/api/tests/auth.routes.test.ts b/api/tests/auth.routes.test.ts index fb6ff9f..9cb63f0 100644 --- a/api/tests/auth.routes.test.ts +++ b/api/tests/auth.routes.test.ts @@ -2,18 +2,33 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import request from "supertest"; import type { FastifyInstance } from "fastify"; import { PrismaClient } from "@prisma/client"; +import argon2 from "argon2"; import { buildApp } from "../src/server"; const prisma = new PrismaClient(); let app: FastifyInstance; +function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null { + if (!setCookie) return null; + for (const raw of setCookie) { + const firstPart = raw.split(";")[0] ?? ""; + const [name, value] = firstPart.split("="); + if (name?.trim() === cookieName && value) { + return value.trim(); + } + } + return null; +} + beforeAll(async () => { app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true }); await app.ready(); }); afterAll(async () => { - await app.close(); + if (app) { + await app.close(); + } await prisma.$disconnect(); }); @@ -24,6 +39,26 @@ describe("Auth routes", () => { expect(res.body.code).toBe("UNAUTHENTICATED"); }); + it("rejects spoofed x-user-id when auth is enabled", async () => { + const res = await request(app.server) + .get("/dashboard") + .set("x-user-id", "spoofed-user-id"); + expect(res.status).toBe(401); + expect(res.body.code).toBe("UNAUTHENTICATED"); + }); + + it("rejects weak passwords on registration", async () => { + const agent = request.agent(app.server); + const email = `weak-${Date.now()}@test.dev`; + const password = "weakpass123"; + const register = await agent.post("/auth/register").send({ email, password }); + expect(register.status).toBe(400); + expect(register.body.ok).toBe(false); + + const user = await prisma.user.findUnique({ where: { email } }); + expect(user).toBeNull(); + }); + it("registers a user and grants access via cookie session", async () => { const agent = request.agent(app.server); const email = `reg-${Date.now()}@test.dev`; @@ -31,9 +66,10 @@ describe("Auth routes", () => { const register = await agent.post("/auth/register").send({ email, password }); expect(register.status).toBe(200); + expect(register.body.needsVerification).toBe(true); const dash = await agent.get("/dashboard"); - expect(dash.status).toBe(200); + expect(dash.status).toBe(401); const created = await prisma.user.findUniqueOrThrow({ where: { email } }); const [catCount, planCount] = await Promise.all([ @@ -52,7 +88,10 @@ describe("Auth routes", () => { const password = "SupersAFE123!"; await agent.post("/auth/register").send({ email, password }); - await agent.post("/auth/logout"); + await prisma.user.update({ + where: { email }, + data: { emailVerified: true }, + }); const login = await agent.post("/auth/login").send({ email, password }); expect(login.status).toBe(200); @@ -69,15 +108,54 @@ describe("Auth routes", () => { const password = "SupersAFE123!"; await agent.post("/auth/register").send({ email, password }); + await prisma.user.update({ + where: { email }, + data: { emailVerified: true }, + }); + const login = await agent.post("/auth/login").send({ email, password }); + expect(login.status).toBe(200); + const csrf = readCookieValue(login.headers["set-cookie"], "csrf"); + expect(csrf).toBeTruthy(); const session = await agent.get("/auth/session"); expect(session.status).toBe(200); expect(session.body.userId).toBeDefined(); - await agent.post("/auth/logout"); + await agent + .post("/auth/logout") + .set("x-csrf-token", csrf as string); const afterLogout = await agent.get("/dashboard"); expect(afterLogout.status).toBe(401); await prisma.user.deleteMany({ where: { email } }); }); + + it("locks login after repeated failed attempts", async () => { + const agent = request.agent(app.server); + const email = `locked-${Date.now()}@test.dev`; + const password = "SupersAFE123!"; + await prisma.user.create({ + data: { + email, + passwordHash: await argon2.hash(password), + emailVerified: true, + }, + }); + + for (let attempt = 1; attempt <= 4; attempt++) { + const res = await agent.post("/auth/login").send({ email, password: "WrongPass123!" }); + expect(res.status).toBe(401); + } + + const locked = await agent.post("/auth/login").send({ email, password: "WrongPass123!" }); + expect(locked.status).toBe(429); + expect(locked.body.code).toBe("LOGIN_LOCKED"); + expect(locked.headers["retry-after"]).toBeTruthy(); + + const blockedValid = await agent.post("/auth/login").send({ email, password }); + expect(blockedValid.status).toBe(429); + expect(blockedValid.body.code).toBe("LOGIN_LOCKED"); + + await prisma.user.deleteMany({ where: { email } }); + }); }); diff --git a/api/tests/cryptographic-failures.runtime.test.ts b/api/tests/cryptographic-failures.runtime.test.ts new file mode 100644 index 0000000..7cc4406 --- /dev/null +++ b/api/tests/cryptographic-failures.runtime.test.ts @@ -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, 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); + }); +}); diff --git a/api/tests/cryptographic-failures.test.ts b/api/tests/cryptographic-failures.test.ts new file mode 100644 index 0000000..cffe739 --- /dev/null +++ b/api/tests/cryptographic-failures.test.ts @@ -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 | undefined; + return !!opts?.sign && !!opts?.verify; + }); + expect(jwtCall).toBeTruthy(); + const jwtOptions = jwtCall?.[1] as Record; + 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(); + } + }); +}); diff --git a/api/tests/identification-auth-failures.test.ts b/api/tests/identification-auth-failures.test.ts new file mode 100644 index 0000000..76c9568 --- /dev/null +++ b/api/tests/identification-auth-failures.test.ts @@ -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 } }); + }); +}); diff --git a/api/tests/injection-safety.test.ts b/api/tests/injection-safety.test.ts new file mode 100644 index 0000000..73c1fff --- /dev/null +++ b/api/tests/injection-safety.test.ts @@ -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) { + 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 }); + } + }); +}); diff --git a/api/tests/insecure-design.test.ts b/api/tests/insecure-design.test.ts new file mode 100644 index 0000000..e8d7704 --- /dev/null +++ b/api/tests/insecure-design.test.ts @@ -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 } }); + }); +}); diff --git a/api/tests/security-logging-monitoring-failures.test.ts b/api/tests/security-logging-monitoring-failures.test.ts new file mode 100644 index 0000000..f599d89 --- /dev/null +++ b/api/tests/security-logging-monitoring-failures.test.ts @@ -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> = []; + +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; + 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"); + }); +}); diff --git a/api/tests/security-misconfiguration.test.ts b/api/tests/security-misconfiguration.test.ts new file mode 100644 index 0000000..2b50a3b --- /dev/null +++ b/api/tests/security-misconfiguration.test.ts @@ -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; + 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"'); + }); +}); diff --git a/api/tests/server-side-request-forgery.test.ts b/api/tests/server-side-request-forgery.test.ts new file mode 100644 index 0000000..a373258 --- /dev/null +++ b/api/tests/server-side-request-forgery.test.ts @@ -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([]); + }); +}); diff --git a/api/tests/setup.ts b/api/tests/setup.ts index 8f9d876..02d9ecd 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -1,16 +1,49 @@ import { execSync } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; import { beforeAll, afterAll } from "vitest"; import { PrismaClient } from "@prisma/client"; +function readEnvValue(filePath: string, key: string): string | undefined { + if (!existsSync(filePath)) return undefined; + const content = readFileSync(filePath, "utf8"); + const line = content + .split(/\r?\n/) + .find((raw) => raw.trim().startsWith(`${key}=`)); + if (!line) return undefined; + const value = line.slice(line.indexOf("=") + 1).trim(); + return value.length > 0 ? value : undefined; +} + +function resolveDatabaseUrl(): string { + if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim(); + if (process.env.BACKUP_DATABASE_URL?.trim()) return process.env.BACKUP_DATABASE_URL.trim(); + if (process.env.DATABASE_URL?.trim()) return process.env.DATABASE_URL.trim(); + + const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")]; + for (const envPath of envPaths) { + const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL"); + if (testUrl) return testUrl; + const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL"); + if (backupUrl) return backupUrl; + const dbUrl = readEnvValue(envPath, "DATABASE_URL"); + if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:"); + } + + return "postgres://app:app@127.0.0.1:5432/skymoney"; +} + process.env.NODE_ENV = process.env.NODE_ENV || "test"; -process.env.DATABASE_URL = - process.env.DATABASE_URL || - "postgres://app:app@localhost:5432/skymoney"; -process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port +process.env.DATABASE_URL = resolveDatabaseUrl(); +process.env.PORT = process.env.PORT || "8081"; process.env.HOST ??= "127.0.0.1"; process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || ""; process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1"; process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1"; +process.env.JWT_SECRET = + process.env.JWT_SECRET || "test-jwt-secret-32-chars-min-abcdef"; +process.env.COOKIE_SECRET = + process.env.COOKIE_SECRET || "test-cookie-secret-32-chars-abcdef"; export const prisma = new PrismaClient(); @@ -25,8 +58,14 @@ export async function resetUser(userId: string) { } beforeAll(async () => { - // make sure the schema is applied before running tests - execSync("npx prisma migrate deploy", { stdio: "inherit" }); + // Optional schema bootstrap for CI/local environments that can run Prisma CLI. + if (process.env.TEST_APPLY_SCHEMA === "1") { + try { + execSync("npx prisma migrate deploy", { stdio: "inherit" }); + } catch { + execSync("npx prisma db push --skip-generate --accept-data-loss", { stdio: "inherit" }); + } + } // Ensure a clean slate: wipe all tables to avoid cross-file leakage await prisma.$transaction([ diff --git a/api/tests/software-data-integrity-failures.test.ts b/api/tests/software-data-integrity-failures.test.ts new file mode 100644 index 0000000..8e4b181 --- /dev/null +++ b/api/tests/software-data-integrity-failures.test.ts @@ -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) { + 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 }); + } + }); +}); diff --git a/api/tests/software-supply-chain-failures.test.ts b/api/tests/software-supply-chain-failures.test.ts new file mode 100644 index 0000000..f74a3a3 --- /dev/null +++ b/api/tests/software-supply-chain-failures.test.ts @@ -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+/); + }); +}); diff --git a/api/vitest.config.ts b/api/vitest.config.ts index 67546a1..653d50a 100644 --- a/api/vitest.config.ts +++ b/api/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.test.ts"], + exclude: ["tests/security-misconfiguration.test.ts"], // run single-threaded to keep DB tests deterministic pool: "threads", poolOptions: { threads: { singleThread: true } }, @@ -11,9 +12,8 @@ export default defineConfig({ env: { NODE_ENV: "test", AUTH_DISABLED: "1", - DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney", SEED_DEFAULT_BUDGET: "1" }, setupFiles: ['tests/setup.ts'], }, -}); \ No newline at end of file +}); diff --git a/api/vitest.security.config.ts b/api/vitest.security.config.ts new file mode 100644 index 0000000..31e7ab0 --- /dev/null +++ b/api/vitest.security.config.ts @@ -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", + }, + }, +});