From ab0d4857dbd12ade8365341fd9648220dc5a29b3 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 2 Jun 2026 10:30:42 -0400 Subject: [PATCH] web security audit fixes --- .env.example | 3 + bun.lock | 127 +++++++- tasks/ios-production/README.md | 8 +- .../10-fix-voiceprint-resource-exhaustion.md | 140 ++++++--- tasks/security-fixes/README.md | 14 +- web/package.json | 1 + web/src/lib/html-utils.ts | 21 +- web/src/lib/url-validation.ts | 12 +- web/src/lib/websocket.test.ts | 64 +++- web/src/lib/websocket.ts | 54 +++- web/src/middleware.test.ts | 121 +++++--- web/src/middleware.ts | 30 +- web/src/server/api/routers/billing.test.ts | 12 +- web/src/server/api/utils.test.ts | 278 +++++++++++++++--- web/src/server/api/utils.ts | 52 +++- web/src/server/lib/cors-validation.ts | 49 +++ web/src/server/lib/ratelimit.ts | 8 +- web/src/server/services/billing.service.ts | 32 +- .../server/services/reports/generator.test.ts | 200 +++++++++++-- web/src/server/services/reports/generator.ts | 110 ++++++- .../services/voiceprint.service.test.ts | 205 ++++++++++++- web/src/server/services/voiceprint.service.ts | 61 +++- .../voiceprint/audio.processor.test.ts | 25 ++ .../services/voiceprint/audio.processor.ts | 43 ++- .../services/voiceprint/storage.test.ts | 67 +++++ web/src/server/services/voiceprint/storage.ts | 79 ++++- 26 files changed, 1527 insertions(+), 289 deletions(-) create mode 100644 web/src/server/lib/cors-validation.ts diff --git a/.env.example b/.env.example index 1cc2261..156572d 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ PORT=3000 NODE_ENV="development" LOG_LEVEL="info" APP_URL="http://localhost:3000" +# Explicit CORS origin allowlist (comma-separated, validated before use) +# Overrides/extends APP_URL for CORS. Example: VALID_CORS_ORIGINS="https://app.kordant.com,https://admin.kordant.com" +VALID_CORS_ORIGINS="" # Auth JWT_SECRET="" diff --git a/bun.lock b/bun.lock index 25c0dc5..759eac1 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "dependencies": { "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", - "superjson": "^2.2.1", + "superjson": "^2.2.6", }, "devDependencies": { "@types/chrome": "^0.0.280", @@ -45,12 +45,18 @@ "bcryptjs": "^3.0.3", "bullmq": "^5.77.3", "clerk-solidjs": "^2.0.10", + "dompurify": "^3.4.7", "drizzle-orm": "^0.45.2", "firebase-admin": "^13.10.0", + "imapflow": "^1.3.4", "ioredis": "^5.10.1", + "isomorphic-dompurify": "^3.15.0", "jose": "^5", + "marked": "^18.0.4", "node-cron": "^4.2.1", - "pg": "^8.21.0", + "onnxruntime-node": "^1.26.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "puppeteer": "^25.0.4", "resend": "^6.12.4", "solid-js": "^1.9.5", @@ -63,11 +69,12 @@ "ws": "^8.21.0", }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node-cron": "^3.0.11", - "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", "drizzle-kit": "^0.31.10", "jsdom": "^29.1.1", + "playwright": "^1.60.0", "tsx": "^4.22.3", "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.5", @@ -377,8 +384,12 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.7.0", "", { "dependencies": { "@poppinss/colors": "4.1.6", "@sindresorhus/is": "7.2.0", "supports-color": "10.2.2" } }, "sha512-0UTYalzk2t6S4rA2uHOz5bSSW2CHdv4vggJI6Alg90yvl0UgXs6XSXpH96OH+bRkX4J/06djv29pqXJ0lq5Kag=="], @@ -679,6 +690,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -709,6 +722,8 @@ "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], + "@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.12", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.8", "libqp": "2.1.1" } }, "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "5.0.1" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -717,6 +732,8 @@ "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "8.16.0" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], + "adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4.4.3" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -747,6 +764,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "1.16.0", "form-data": "4.0.5", "https-proxy-agent": "5.0.1", "proxy-from-env": "2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "b4a": ["b4a@1.8.1", "", {}, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], @@ -839,6 +858,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -887,6 +908,8 @@ "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "5.0.0", "whatwg-url": "16.0.1" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], "db0": ["db0@0.3.4", "", { "optionalDependencies": { "@libsql/client": "0.15.15", "drizzle-orm": "0.45.2" } }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="], @@ -901,8 +924,12 @@ "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -923,6 +950,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "3.0.4", "tslib": "2.8.1" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "5.6.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -953,6 +982,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.22.0", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.3" } }, "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A=="], @@ -1003,12 +1034,16 @@ "farmhash-modern": ["farmhash-modern@1.1.0", "", {}, "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA=="], + "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "@nodelib/fs.walk": "1.2.8", "glob-parent": "5.1.2", "merge2": "1.4.1", "micromatch": "4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "1.5.0", "xml-naming": "0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], @@ -1075,6 +1110,10 @@ "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + "global-agent": ["global-agent@4.1.3", "", { "dependencies": { "globalthis": "^1.0.2", "matcher": "^4.0.0", "semver": "^7.3.5", "serialize-error": "^8.1.0" } }, "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "globby": ["globby@16.2.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "fast-glob": "3.3.3", "ignore": "7.0.5", "is-path-inside": "4.0.0", "slash": "5.1.0", "unicorn-magic": "0.4.0" } }, "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q=="], "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "1.5.1", "ecdsa-sig-formatter": "1.0.11", "gaxios": "7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "4.0.1" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], @@ -1095,6 +1134,8 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -1105,6 +1146,8 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "3.0.4" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "1.15.1" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], @@ -1129,10 +1172,14 @@ "httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "imapflow": ["imapflow@1.3.5", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.12", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libmime": "5.3.8", "libqp": "2.1.1", "nodemailer": "8.0.10", "pino": "10.3.1", "socks": "2.8.9" } }, "sha512-1cWnj9V8eJuYizxfb4nzD2C+cE27pUOIg571d/U9pg046bJnz/d+rnp2HzXKqX9bYmkvxoQqw2w3TMGpmfiBoA=="], + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "1.0.1", "resolve-from": "4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-in-the-middle": ["import-in-the-middle@3.0.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA=="], @@ -1141,6 +1188,8 @@ "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "1.1.2", "debug": "4.4.3", "denque": "2.1.0", "lodash.defaults": "4.2.0", "lodash.isarguments": "3.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -1179,6 +1228,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic-dompurify": ["isomorphic-dompurify@3.15.0", "", { "dependencies": { "dompurify": "^3.4.7", "jsdom": "^29.1.1" } }, "sha512-9ZtkbQ8+SgNf6LuDAdu9bq23dVXMIGNM8ZYnyl2MufyZiSD5dqAUJcyjtYZz7B80HuPpEn/f0NCS6zKvavHtfA=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "3.2.2", "make-dir": "4.0.0", "supports-color": "7.2.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], @@ -1191,6 +1242,8 @@ "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], @@ -1225,6 +1278,12 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "2.3.8" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="], + + "libmime": ["libmime@5.3.8", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.7.2", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng=="], + + "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1303,6 +1362,10 @@ "map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="], + "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], + + "matcher": ["matcher@4.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@ungap/structured-clone": "1.3.1", "devlop": "1.1.0", "micromark-util-sanitize-uri": "2.0.1", "trim-lines": "3.0.1", "unist-util-position": "5.0.0", "unist-util-visit": "5.1.0", "vfile": "6.0.3" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], @@ -1335,6 +1398,8 @@ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "5.0.6" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "7.1.3" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], @@ -1379,6 +1444,8 @@ "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + "nodemailer": ["nodemailer@8.0.10", "", {}, "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ=="], + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "3.0.1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1387,18 +1454,26 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "2.0.5", "node-fetch-native": "1.6.7", "ufo": "1.6.4" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "1.0.0", "regex": "5.1.1", "regex-recursion": "5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "onnxruntime-common": ["onnxruntime-common@1.26.0", "", {}, "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw=="], + + "onnxruntime-node": ["onnxruntime-node@1.26.0", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^4.1.3", "onnxruntime-common": "1.26.0" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "5.5.0", "define-lazy-prop": "3.0.0", "is-in-ssh": "1.0.0", "is-inside-container": "1.0.0", "powershell-utils": "0.1.0", "wsl-utils": "0.3.1" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -1451,8 +1526,20 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "0.2.4", "exsolve": "1.0.8", "pathe": "2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "3.3.12", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1473,6 +1560,8 @@ "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], @@ -1499,6 +1588,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -1513,6 +1604,8 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "1.2.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], @@ -1555,16 +1648,24 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "fresh": "2.0.0", "http-errors": "2.0.1", "mime-types": "3.0.2", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "1.2.1", "statuses": "2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@8.1.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ=="], + "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], @@ -1597,18 +1698,24 @@ "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], "snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "3.0.4", "tslib": "2.8.1" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="], "snakecase-keys": ["snakecase-keys@8.0.1", "", { "dependencies": { "map-obj": "4.3.0", "snake-case": "3.0.4", "type-fest": "4.41.0" } }, "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw=="], + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "3.2.3", "seroval": "1.5.4", "seroval-plugins": "1.5.4" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "7.29.7", "@babel/helper-module-imports": "7.29.7", "@babel/types": "7.29.7" }, "peerDependencies": { "solid-js": "1.9.13" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], "solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "1.9.13" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1651,6 +1758,8 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], "stripe": ["stripe@22.1.1", "", { "optionalDependencies": { "@types/node": "25.9.1" } }, "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA=="], @@ -1691,6 +1800,8 @@ "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "1.8.1" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -1955,6 +2066,8 @@ "gcp-metadata/gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "3.0.2", "https-proxy-agent": "7.0.6", "node-fetch": "3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "global-agent/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "google-auth-library/gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "3.0.2", "https-proxy-agent": "7.0.6", "node-fetch": "3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], "google-gax/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "1.5.1", "ecdsa-sig-formatter": "1.0.11", "gaxios": "6.7.1", "gcp-metadata": "6.1.1", "gtoken": "7.1.0", "jws": "4.0.1" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], @@ -1981,6 +2094,8 @@ "make-dir/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "merge-anything/is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1993,12 +2108,16 @@ "nitropack/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rollup-plugin-visualizer/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "9.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "string-width": "7.2.0", "y18n": "5.0.8", "yargs-parser": "22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "serialize-error/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + "snakecase-keys/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2011,6 +2130,8 @@ "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "unimport/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "picomatch": "4.0.4", "webpack-virtual-modules": "0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], diff --git a/tasks/ios-production/README.md b/tasks/ios-production/README.md index 5eb52e1..1a2fe15 100644 --- a/tasks/ios-production/README.md +++ b/tasks/ios-production/README.md @@ -13,10 +13,10 @@ Status legend: [ ] todo, [~] in-progress, [x] done - [x] 04 — TestFlight Beta Distribution → `04-testflight-beta.md` ### Security Hardening -- [ ] 05 — Certificate Pinning & TLS Validation → `05-certificate-pinning.md` -- [ ] 06 — Jailbreak Detection & Runtime Security → `06-jailbreak-detection.md` -- [ ] 07 — Keychain & Data Protection Audit → `07-keychain-data-protection.md` -- [ ] 08 — OAuth & Social Login Integration → `08-oauth-social-login.md` +- [~] 05 — Certificate Pinning & TLS Validation → `05-certificate-pinning.md` +- [~] 06 — Jailbreak Detection & Runtime Security → `06-jailbreak-detection.md` +- [~] 07 — Keychain & Data Protection Audit → `07-keychain-data-protection.md` +- [~] 08 — OAuth & Social Login Integration → `08-oauth-social-login.md` ### Performance Optimization - [ ] 09 — Image Caching & Lazy Loading → `09-image-caching.md` diff --git a/tasks/security-fixes/10-fix-voiceprint-resource-exhaustion.md b/tasks/security-fixes/10-fix-voiceprint-resource-exhaustion.md index ff3f773..0f63e67 100644 --- a/tasks/security-fixes/10-fix-voiceprint-resource-exhaustion.md +++ b/tasks/security-fixes/10-fix-voiceprint-resource-exhaustion.md @@ -1,57 +1,99 @@ -# 10. Fix VoicePrint resource exhaustion via unbounded audio upload +# Task 10: Fix VoicePrint Resource Exhaustion via Unbounded Audio Upload -meta: - id: security-fixes-10 - feature: security-fixes - priority: P1 - depends_on: [] - tags: [implementation, tests-required, medium-severity] +## Vulnerability -objective: -- Prevent memory exhaustion by enforcing maximum payload size on VoicePrint audio endpoints +The VoicePrint module was vulnerable to resource exhaustion attacks through unbounded audio uploads. An attacker with a valid account could: -deliverables: -- `maxLength` constraint on `AnalyzeAudioSchema` in `web/src/server/api/schemas/voiceprint.ts` -- Request body size limit middleware for audio endpoints -- Size validation in `voiceprint.service.ts` before base64 decoding -- Unit tests for size limits +1. **Memory exhaustion**: Upload extremely large audio files, causing the server to allocate massive intermediate buffers during audio preprocessing (Float64Array + Int16Array + Buffer = 8x+ input size) +2. **Disk exhaustion**: Upload unlimited audio files with no per-user storage quota, filling the disk +3. **CPU exhaustion**: Submit unlimited analysis/enrollment requests with no rate limiting, consuming CPU for audio preprocessing and Azure API calls +4. **Enrollment table bloat**: Create unlimited voice enrollments with no per-user cap -steps: -1. Examine `AnalyzeAudioSchema` at `web/src/server/api/schemas/voiceprint.ts:8-10` and `analyzeAudio()` at `web/src/server/services/voiceprint.service.ts:135-140` -2. Add `maxLength` to the audio schema: - - Calculate a reasonable limit: A 60-second mono 16kHz WAV is ~1.2MB raw, ~1.6MB base64 - - Set `maxLength` to ~2MB base64 (~1.5MB raw) as a safe default - - Consider making it configurable via an environment variable -3. Add a request body size limit in the tRPC middleware or at the HTTP layer: - - Reject requests with body size > configured limit before processing - - Return a clear error message to the client -4. Add a pre-decode size check in `analyzeAudio()`: - - Calculate the decoded size from the base64 string length (`base64Length * 0.75`) - - Reject if the decoded size exceeds the configured memory limit -5. Update `protectedProcedure` rate limit for voiceprint endpoints if not already covered by task 04 +## Attack Vectors -tests: -- Unit: `AnalyzeAudioSchema` rejects payloads exceeding `maxLength` -- Unit: `analyzeAudio()` rejects base64 strings that would decode to > configured memory limit -- Unit: Valid audio payloads within the limit are accepted -- Integration: Sending a 100MB base64 payload to the audio endpoint is rejected with a size error -- Integration: Sending a valid 30-second audio recording succeeds +- `POST /voiceprint/createEnrollment` — No rate limit, no enrollment count cap +- `POST /voiceprint/enrollAdditionalSample` — No rate limit +- `POST /voiceprint/analyzeAudio` — No rate limit on memory-intensive analysis +- `POST /voiceprint/analyzeCallRecording` — No rate limit +- `saveAudio()` — No deduplication, no storage quota check +- `preprocessAudio()` — No input size limit, no duration validation from header -acceptance_criteria: -- Audio schema enforces `maxLength` on the base64 payload -- Request body size limit middleware rejects oversized requests before processing -- Pre-decode size check prevents memory exhaustion from valid-length but high-entropy payloads -- Clear error messages are returned when size limits are exceeded -- Valid audio recordings within the size limit are processed normally +## Fixes Implemented -validation: -- `cd web && bun test` — all tests pass -- Send a base64 payload exceeding the maxLength and verify it is rejected -- Send a valid audio recording and verify it is processed correctly -- Verify the rate limit for voiceprint endpoints is appropriate (task 04) +### 1. Rate Limiting (`voiceprint.service.ts`) -notes: -- Finding p8-010: A 100MB base64 payload consumes 300MB+ memory per request -- The `protectedProcedure` rate limit (100/min) is insufficient — at 100 requests/min with 100MB payloads, that's 10GB/min of memory pressure -- Consider streaming or chunked upload for large audio files instead of base64 in the request body -- The maxLength should account for realistic use cases: voice biometrics typically need 3-30 seconds of audio +Added rate limiting using the existing `memory` tier (10 requests/hour per user) to all audio upload endpoints: +- `createEnrollment` — Rate limited before enrollment count check +- `enrollAdditionalSample` — Rate limited before enrollment lookup +- `analyzeAudio` — Rate limited after access check but before processing +- `analyzeCallRecording` — Rate limited after access check + +### 2. Per-User Enrollment Limit (`voiceprint.service.ts`) + +Added `VOICEPRINT_MAX_ENROLLMENTS` env var (default: 5) to cap active enrollments per user. `createEnrollment` now checks the count before proceeding. + +### 3. Per-User Storage Quota (`storage.ts`) + +- Added `VOICEPRINT_MAX_USER_STORAGE_BYTES` env var (default: 50MB) +- Added `getUserStorageUsage(userId)` to calculate total disk usage +- Added `checkStorageQuota(userId, fileSizeBytes)` to enforce quota before writes +- `saveAudio` now checks quota before writing new files + +### 4. Audio Deduplication (`storage.ts`) + +`saveAudio` now: +- Computes SHA-256 hash of input +- Checks if file with same hash already exists +- Returns `isNew: false` if file exists (skips write) +- Returns `isNew: true` if file is new + +This prevents redundant storage and CPU waste from duplicate uploads. + +### 5. Input Size Validation (`audio.processor.ts`) + +Added `VOICEPRINT_MAX_INPUT_BYTES` env var (default: 5MB) to reject oversized WAV files before any processing. Error message includes suggested action. + +### 6. Duration Validation from Header (`audio.processor.ts`) + +Before allocating sample buffers, the processor now: +- Parses WAV header to extract duration +- Rejects files exceeding `MAX_DURATION_SEC + 30` (60s total) +- This prevents loading multi-hour WAV files into memory + +### 7. Storage Usage in Stats (`voiceprint.service.ts`) + +`getUsageStats` now returns `storageUsedBytes` and `storageUsedMB` so users can monitor their disk usage. + +## Configuration + +All limits are configurable via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `VOICEPRINT_MAX_BASE64_LENGTH` | 2621440 (~2.6MB) | Max base64 string length (schema-level) | +| `VOICEPRINT_MAX_DECODED_SIZE` | 2097152 (2MB) | Max decoded buffer size | +| `VOICEPRINT_MAX_INPUT_BYTES` | 5242880 (5MB) | Max raw WAV file size | +| `VOICEPRINT_MAX_USER_STORAGE_BYTES` | 52428800 (50MB) | Per-user disk storage quota | +| `VOICEPRINT_MAX_ENROLLMENTS` | 5 | Max active enrollments per user | + +Rate limiting uses the existing `memory` tier: 10 requests per hour per user (via Redis sliding window). + +## Files Modified + +- `web/src/server/services/voiceprint.service.ts` — Rate limiting, enrollment limits, storage stats +- `web/src/server/services/voiceprint/storage.ts` — Storage quota, deduplication, usage tracking +- `web/src/server/services/voiceprint/audio.processor.ts` — Input size limit, duration validation +- `web/src/server/services/voiceprint.service.test.ts` — Tests for all new protections +- `web/src/server/services/voiceprint/storage.test.ts` — Tests for quota and deduplication +- `web/src/server/services/voiceprint/audio.processor.test.ts` — Tests for size/duration validation + +## Defense in Depth + +The fix implements multiple layers of protection: + +1. **Schema-level** (valibot): `MAX_BASE64_LENGTH` rejects oversized base64 at request parsing +2. **Service-level**: `validateDecodedSize` checks decoded buffer size before allocation +3. **Processor-level**: `MAX_INPUT_BYTES` rejects large WAV files, header duration check rejects long audio +4. **Storage-level**: Quota check before disk write, deduplication prevents redundant storage +5. **Rate limiting**: Redis-based sliding window prevents rapid-fire requests +6. **Enrollment cap**: Prevents unlimited enrollment creation diff --git a/tasks/security-fixes/README.md b/tasks/security-fixes/README.md index 69ae9a0..6990448 100644 --- a/tasks/security-fixes/README.md +++ b/tasks/security-fixes/README.md @@ -5,12 +5,12 @@ Objective: Remediate all 11 confirmed security findings from the piolium balance Status legend: [ ] todo, [~] in-progress, [x] done Tasks -- [ ] 01 — Fix stored XSS via unsanitized innerHTML in blog rendering → `01-fix-stored-xss-blog-rendering.md` -- [ ] 02 — Fix SSRF via Puppeteer --no-sandbox in report generation → `02-fix-puppeteer-ssrf-report-gen.md` -- [ ] 03 — Fix open redirect via unvalidated return URL in Stripe checkout → `03-fix-open-redirect-stripe-return-url.md` -- [ ] 04 — Fix rate limit bypass via incomplete sensitive path list → `04-fix-rate-limit-substring-bypass.md` -- [ ] 05 — Fix CORS origin trust from unvalidated APP_URL env var → `05-fix-cors-origin-env-var-validation.md` -- [ ] 06 — Fix webhook type coercion bypassing TypeScript safety → `06-fix-webhook-type-coercion.md` +- [x] 01 — Fix stored XSS via unsanitized innerHTML in blog rendering → `01-fix-stored-xss-blog-rendering.md` +- [x] 02 — Fix SSRF via Puppeteer --no-sandbox in report generation → `02-fix-puppeteer-ssrf-report-gen.md` +- [x] 03 — Fix open redirect via unvalidated return URL in Stripe checkout → `03-fix-open-redirect-stripe-return-url.md` +- [x] 04 — Fix rate limit bypass via incomplete sensitive path list → `04-fix-rate-limit-substring-bypass.md` +- [x] 05 — Fix CORS origin trust from unvalidated APP_URL env var → `05-fix-cors-origin-env-var-validation.md` +- [x] 06 — Fix webhook type coercion bypassing TypeScript safety → `06-fix-webhook-type-coercion.md` - [x] 07 — Fix webhook replay via missing event ID deduplication → `07-fix-webhook-replay-missing-dedup.md` - [x] 08 — Fix WebSocket JWT leakage via query parameter → `08-fix-websocket-jwt-query-param-leak.md` - [x] 09 — Fix WebSocket no Origin header validation → `09-fix-websocket-origin-validation.md` @@ -22,4 +22,4 @@ Dependencies - 09 depends on 08 (WebSocket JWT header auth is the prerequisite for Origin validation to be meaningful) Exit criteria -- The feature is complete when all 11 findings have been remediated, each with passing tests, and no regression is introduced to the existing codebase. +- The feature is complete when all 11 findings have been remediated, each wit passing tests, and no regression is introduced to the existing codebase. diff --git a/web/package.json b/web/package.json index 38f319c..064ea93 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "ioredis": "^5.10.1", "isomorphic-dompurify": "^3.15.0", "jose": "^5", + "marked": "^18.0.4", "node-cron": "^4.2.1", "onnxruntime-node": "^1.26.0", "pino": "^10.3.1", diff --git a/web/src/lib/html-utils.ts b/web/src/lib/html-utils.ts index 65f873d..8ea6bd2 100644 --- a/web/src/lib/html-utils.ts +++ b/web/src/lib/html-utils.ts @@ -1,21 +1,25 @@ import DOMPurify from "isomorphic-dompurify"; +import { marked } from "marked"; /** * Sanitizes HTML content by stripping all XSS vectors (script tags, * event handlers, javascript:/data: URIs) while preserving safe * formatting elements (headings, paragraphs, links, lists, code). + * + * Uses a strict ALLOWED_URI_REGEXP that only permits http, https, mailto, + * tel, and relative URLs — explicitly blocking javascript:, data:, and vbscript:. */ export function sanitizeHtml(rawHtml: string): string { return DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: [ "h1", "h2", "h3", "h4", "h5", "h6", - "h7", "h8", "p", "a", "ul", "ol", "li", "strong", "em", "b", "i", "u", "code", "pre", "br", "hr", "blockquote", "img", + "table", "thead", "tbody", "tr", "th", "td", ], ALLOWED_ATTR: [ "href", @@ -26,6 +30,19 @@ export function sanitizeHtml(rawHtml: string): string { "rel", "target", ], - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + // Only allow safe URI schemes. Explicitly blocks javascript:, data:, vbscript: + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|\/|#|\.\?\/)/i, + // Explicitly forbid dangerous tags even if they slip through + FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form", "input", "button", "select", "textarea"], }); } + +/** + * Converts markdown content to sanitized HTML. + * Uses marked for markdown parsing and DOMPurify for sanitization. + * This is the safe replacement for the previous contentToHtml() function. + */ +export function markdownToHtml(markdown: string): string { + const html = marked.parse(markdown, { async: false }) as string; + return sanitizeHtml(html); +} diff --git a/web/src/lib/url-validation.ts b/web/src/lib/url-validation.ts index bdc801b..d110130 100644 --- a/web/src/lib/url-validation.ts +++ b/web/src/lib/url-validation.ts @@ -1,4 +1,4 @@ -import { object, string, minLength, custom } from "valibot"; +import { custom } from "valibot"; function getAllowlist(): string[] { const raw = process.env.ALLOWED_RETURN_DOMAINS ?? "app.kordant.com,admin.kordant.com"; @@ -58,12 +58,8 @@ export function validateReturnUrl(url: string): boolean { */ export const returnUrlSchema = custom( (value) => { - if (typeof value !== "string" || !validateReturnUrl(value)) { - return { - message: - "Return URL must point to a trusted domain. Only app.kordant.com and admin.kordant.com are allowed.", - }; - } - return value; + if (typeof value !== "string") return false; + return validateReturnUrl(value); }, + "Return URL must point to a trusted domain. Only app.kordant.com and admin.kordant.com are allowed.", ); diff --git a/web/src/lib/websocket.test.ts b/web/src/lib/websocket.test.ts index 237a4b8..fd86b9e 100644 --- a/web/src/lib/websocket.test.ts +++ b/web/src/lib/websocket.test.ts @@ -5,10 +5,13 @@ function createMockWs() { let onopen: (() => void) | null = null; let onclose: ((event: { code: number }) => void) | null = null; let onmessage: ((event: { data: string }) => void) | null = null; + const sentMessages: string[] = []; return { readyState: 1, - send: vi.fn(), + send: vi.fn((data: string) => { + sentMessages.push(data); + }), close: vi.fn((code?: number) => { onclose?.({ code: code ?? 1000 }); }), @@ -22,6 +25,7 @@ function createMockWs() { OPEN: 1, CLOSING: 2, CLOSED: 3, + sentMessages, }; } @@ -71,23 +75,37 @@ describe("WebSocket client", () => { return result; } - it("should call WebSocket constructor with token", () => { - const ws = new globalThis.WebSocket("ws://test/tok"); + it("should connect WITHOUT token in URL (no JWT leakage)", () => { + const ws = new globalThis.WebSocket("ws://test"); expect(wsConstructorUrls).toHaveLength(1); - expect(wsConstructorUrls[0]).toContain("ws://test/tok"); + expect(wsConstructorUrls[0]).toBe("ws://test"); + expect(wsConstructorUrls[0]).not.toContain("token="); expect(ws).toBe(mockWs); }); - it("should connect and set connected status", async () => { + it("should connect and send post-connection auth message", async () => { const { createWebSocketClient } = await import("./websocket"); const client = runWithRoot(() => createWebSocketClient()); client.connect(); + // WebSocket should connect without token in URL expect(wsConstructorUrls).toHaveLength(1); - expect(wsConstructorUrls[0]).toContain("token=test-session-token"); + expect(wsConstructorUrls[0]).toBe("ws://localhost:3001"); + expect(wsConstructorUrls[0]).not.toContain("token="); + // Trigger onopen to simulate connection established mockWs.onopen?.(); await new Promise((r) => setTimeout(r, 5)); + + // Should have sent auth message with token + expect(mockWs.send).toHaveBeenCalledWith( + JSON.stringify({ type: "auth", token: "test-session-token" }), + ); + + // Simulate auth_success from server + mockWs.onmessage?.({ data: JSON.stringify({ type: "auth_success" }) }); + await new Promise((r) => setTimeout(r, 5)); + expect(client.connectionStatus()).toBe("connected"); client.disconnect(); }); @@ -106,12 +124,35 @@ describe("WebSocket client", () => { expect(client.connectionStatus()).toBe("disconnected"); }); + it("should handle auth_error and close connection", async () => { + const { createWebSocketClient } = await import("./websocket"); + const client = runWithRoot(() => createWebSocketClient()); + client.connect(); + + mockWs.onopen?.(); + await new Promise((r) => setTimeout(r, 5)); + + // Simulate auth_error from server + mockWs.onmessage?.({ + data: JSON.stringify({ type: "auth_error", message: "Invalid token" }), + }); + await new Promise((r) => setTimeout(r, 5)); + + // Should have closed the connection + expect(mockWs.close).toHaveBeenCalled(); + client.disconnect(); + }); + it("should reconnect on unexpected disconnect", async () => { const { createWebSocketClient } = await import("./websocket"); const client = runWithRoot(() => createWebSocketClient()); client.connect(); mockWs.onopen?.(); await new Promise((r) => setTimeout(r, 5)); + + // Auth success + mockWs.onmessage?.({ data: JSON.stringify({ type: "auth_success" }) }); + await new Promise((r) => setTimeout(r, 5)); expect(client.connectionStatus()).toBe("connected"); mockWs.onclose?.({ code: 1006 }); @@ -129,6 +170,10 @@ describe("WebSocket client", () => { mockWs.onopen?.(); await new Promise((r) => setTimeout(r, 5)); + // Auth success first + mockWs.onmessage?.({ data: JSON.stringify({ type: "auth_success" }) }); + await new Promise((r) => setTimeout(r, 5)); + mockWs.onmessage?.({ data: JSON.stringify({ type: "alert", @@ -152,6 +197,9 @@ describe("WebSocket client", () => { client.connect(); mockWs.onopen?.(); await new Promise((r) => setTimeout(r, 5)); + + mockWs.onmessage?.({ data: JSON.stringify({ type: "auth_success" }) }); + await new Promise((r) => setTimeout(r, 5)); expect(client.connectionStatus()).toBe("connected"); client.disconnect(); @@ -164,7 +212,9 @@ describe("WebSocket client", () => { client.connect(); mockWs.onopen?.(); await new Promise((r) => setTimeout(r, 5)); - expect(client.connectionStatus()).toBe("connected"); + + mockWs.onmessage?.({ data: JSON.stringify({ type: "auth_success" }) }); + await new Promise((r) => setTimeout(r, 5)); expect(() => { mockWs.onmessage?.({ data: JSON.stringify({ type: "pong" }) }); diff --git a/web/src/lib/websocket.ts b/web/src/lib/websocket.ts index 447484e..677860c 100644 --- a/web/src/lib/websocket.ts +++ b/web/src/lib/websocket.ts @@ -5,6 +5,7 @@ const RECONNECT_DELAYS = [1000, 2000, 5000, 10_000, 30_000]; const MAX_RECONNECT_ATTEMPTS = 10; const HEARTBEAT_INTERVAL = 30_000; const PONG_TIMEOUT = 10_000; +const AUTH_TIMEOUT = 5_000; export interface AlertPayload { id: string; @@ -39,6 +40,8 @@ export function createWebSocketClient() { let heartbeatTimer: ReturnType | null = null; let pongTimer: ReturnType | null = null; let intentionalClose = false; + let isAuthenticated = false; + let authTimer: ReturnType | null = null; let listeners: Array<(alert: AlertPayload) => void> = []; let statusListeners: Array<(status: ConnectionStatus) => void> = []; let mountedCleanups: Array<() => void> = []; @@ -89,6 +92,30 @@ export function createWebSocketClient() { } } + function startAuthTimeout() { + stopAuthTimeout(); + authTimer = setTimeout(() => { + if (!isAuthenticated && ws) { + intentionalClose = false; + ws.close(); + } + }, AUTH_TIMEOUT); + } + + function stopAuthTimeout() { + if (authTimer) { + clearTimeout(authTimer); + authTimer = null; + } + } + + function sendAuth(token: string) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "auth", token })); + startAuthTimeout(); + } + } + function scheduleReconnect() { if (intentionalClose) return; if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { @@ -119,15 +146,17 @@ export function createWebSocketClient() { } intentionalClose = false; + isAuthenticated = false; notifyStatus("connecting"); try { - ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(token)}`); + // Connect WITHOUT token in URL to prevent JWT leakage in logs + ws = new WebSocket(WS_URL); ws.onopen = () => { reconnectAttempts = 0; - notifyStatus("connected"); - startHeartbeat(); + // Send auth message after connection opens (post-connection auth) + sendAuth(token); }; ws.onmessage = (event) => { @@ -142,6 +171,21 @@ export function createWebSocketClient() { return; } + if (data.type === "auth_success") { + isAuthenticated = true; + stopAuthTimeout(); + notifyStatus("connected"); + startHeartbeat(); + return; + } + + if (data.type === "auth_error") { + stopAuthTimeout(); + intentionalClose = true; + ws?.close(); + return; + } + if (data.type === "alert") { notifyAlert(data.alert as AlertPayload); } @@ -152,6 +196,8 @@ export function createWebSocketClient() { ws.onclose = () => { stopHeartbeat(); + stopAuthTimeout(); + isAuthenticated = false; if (!intentionalClose) { scheduleReconnect(); } else { @@ -169,7 +215,9 @@ export function createWebSocketClient() { function disconnect() { intentionalClose = true; + isAuthenticated = false; stopHeartbeat(); + stopAuthTimeout(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; diff --git a/web/src/middleware.test.ts b/web/src/middleware.test.ts index ec9fe56..d3b102c 100644 --- a/web/src/middleware.test.ts +++ b/web/src/middleware.test.ts @@ -1,73 +1,112 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + validateCorsOrigin, + parseCorsAllowlist, +} from "~/server/lib/cors-validation"; -/** - * Mirrors the isValidCorsOrigin function from middleware.ts - */ -function isValidCorsOrigin(origin: string): boolean { - if (!origin || !origin.trim()) return false; - if (origin === "*") return false; - - try { - const parsed = new URL(origin); - if (!parsed.protocol.match(/^https?:$/)) return false; - if (!parsed.hostname) return false; - return true; - } catch { - return false; - } -} - -describe("isValidCorsOrigin", () => { +describe("validateCorsOrigin", () => { describe("accepted origins", () => { it("accepts valid HTTPS origins", () => { - expect(isValidCorsOrigin("https://app.kordant.com")).toBe(true); - expect(isValidCorsOrigin("https://admin.kordant.com")).toBe(true); - expect(isValidCorsOrigin("https://localhost:3000")).toBe(true); + expect(validateCorsOrigin("https://app.kordant.com")).toBe(true); + expect(validateCorsOrigin("https://admin.kordant.com")).toBe(true); + expect(validateCorsOrigin("https://localhost:3000")).toBe(true); }); it("accepts valid HTTP origins", () => { - expect(isValidCorsOrigin("http://localhost:3000")).toBe(true); - expect(isValidCorsOrigin("http://localhost:3001")).toBe(true); - expect(isValidCorsOrigin("http://127.0.0.1:8080")).toBe(true); + expect(validateCorsOrigin("http://localhost:3000")).toBe(true); + expect(validateCorsOrigin("http://localhost:3001")).toBe(true); + expect(validateCorsOrigin("http://127.0.0.1:8080")).toBe(true); }); it("accepts origins with ports", () => { - expect(isValidCorsOrigin("https://app.kordant.com:8443")).toBe(true); - expect(isValidCorsOrigin("http://localhost:5173")).toBe(true); + expect(validateCorsOrigin("https://app.kordant.com:8443")).toBe(true); + expect(validateCorsOrigin("http://localhost:5173")).toBe(true); }); it("accepts origins with paths", () => { - expect(isValidCorsOrigin("https://app.kordant.com/api")).toBe(true); + expect(validateCorsOrigin("https://app.kordant.com/api")).toBe(true); }); }); describe("rejected origins", () => { it("rejects wildcard", () => { - expect(isValidCorsOrigin("*")).toBe(false); + expect(validateCorsOrigin("*")).toBe(false); }); it("rejects missing scheme", () => { - expect(isValidCorsOrigin("evil.com")).toBe(false); - expect(isValidCorsOrigin("localhost")).toBe(false); - expect(isValidCorsOrigin("app.kordant.com")).toBe(false); + expect(validateCorsOrigin("evil.com")).toBe(false); + expect(validateCorsOrigin("localhost")).toBe(false); + expect(validateCorsOrigin("app.kordant.com")).toBe(false); }); it("rejects non-HTTP schemes", () => { - expect(isValidCorsOrigin("ftp://evil.com")).toBe(false); - expect(isValidCorsOrigin("file:///etc/passwd")).toBe(false); - expect(isValidCorsOrigin("javascript:alert(1)")).toBe(false); - expect(isValidCorsOrigin("data:text/html,test")).toBe(false); + expect(validateCorsOrigin("ftp://evil.com")).toBe(false); + expect(validateCorsOrigin("file:///etc/passwd")).toBe(false); + expect(validateCorsOrigin("javascript:alert(1)")).toBe(false); + expect(validateCorsOrigin("data:text/html,test")).toBe(false); }); it("rejects empty and whitespace strings", () => { - expect(isValidCorsOrigin("")).toBe(false); - expect(isValidCorsOrigin(" ")).toBe(false); - expect(isValidCorsOrigin("\t")).toBe(false); + expect(validateCorsOrigin("")).toBe(false); + expect(validateCorsOrigin(" ")).toBe(false); + expect(validateCorsOrigin("\t")).toBe(false); }); it("rejects malformed URLs", () => { - expect(isValidCorsOrigin("not a url")).toBe(false); - expect(isValidCorsOrigin("://missing-protocol")).toBe(false); + expect(validateCorsOrigin("not a url")).toBe(false); + expect(validateCorsOrigin("://missing-protocol")).toBe(false); }); }); }); + +describe("parseCorsAllowlist", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns empty array for undefined/null/empty input", () => { + expect(parseCorsAllowlist(undefined)).toEqual([]); + expect(parseCorsAllowlist(null)).toEqual([]); + expect(parseCorsAllowlist("")).toEqual([]); + expect(parseCorsAllowlist(" ")).toEqual([]); + }); + + it("parses and validates a comma-separated list of origins", () => { + const result = parseCorsAllowlist( + "https://app.kordant.com,https://admin.kordant.com", + ); + expect(result).toEqual([ + "https://app.kordant.com", + "https://admin.kordant.com", + ]); + }); + + it("filters out invalid origins and warns", () => { + const result = parseCorsAllowlist( + "https://app.kordant.com,evil.com,*,ftp://bad.com", + ); + expect(result).toEqual(["https://app.kordant.com"]); + expect(console.warn).toHaveBeenCalledTimes(3); + }); + + it("rejects http://localhost:9999 when not in the allowlist", () => { + // This origin is not in the configured list + const result = parseCorsAllowlist("https://app.kordant.com"); + expect(result).not.toContain("http://localhost:9999"); + expect(result).toEqual(["https://app.kordant.com"]); + }); + + it("handles whitespace around commas", () => { + const result = parseCorsAllowlist( + " https://app.kordant.com , https://admin.kordant.com ", + ); + expect(result).toEqual([ + "https://app.kordant.com", + "https://admin.kordant.com", + ]); + }); +}); diff --git a/web/src/middleware.ts b/web/src/middleware.ts index ea771d9..be35ace 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -1,6 +1,10 @@ import { createMiddleware, type RequestMiddleware } from "@solidjs/start/middleware"; import { clerkMiddleware } from "clerk-solidjs/start/server"; import { requestLogger } from "~/server/lib/request-logger"; +import { + validateCorsOrigin, + parseCorsAllowlist, +} from "~/server/lib/cors-validation"; const securityHeaders: RequestMiddleware = (event) => { const h = event.response.headers; @@ -18,26 +22,6 @@ const securityHeaders: RequestMiddleware = (event) => { h.set("X-Permitted-Cross-Domain-Policies", "none"); }; -/** - * Validates that an origin string is a well-formed HTTP(S) origin. - * Rejects wildcards, empty strings, non-HTTP schemes, and malformed URLs. - */ -function isValidCorsOrigin(origin: string): boolean { - if (!origin || !origin.trim()) return false; - if (origin === "*") return false; - - try { - const parsed = new URL(origin); - // Only allow http and https schemes - if (!parsed.protocol.match(/^https?:$/)) return false; - // Hostname must not be empty - if (!parsed.hostname) return false; - return true; - } catch { - return false; - } -} - const corsHeaders: RequestMiddleware = (event) => { const origin = event.request.headers.get("origin"); const allowedOrigins = [ @@ -48,13 +32,17 @@ const corsHeaders: RequestMiddleware = (event) => { // Validate APP_URL before trusting it as a CORS origin const appUrl = process.env.APP_URL; if (appUrl) { - if (isValidCorsOrigin(appUrl)) { + if (validateCorsOrigin(appUrl)) { allowedOrigins.push(appUrl); } else { console.warn(`[cors] APP_URL "${appUrl}" is not a valid HTTP(S) origin and will be excluded from CORS allowlist`); } } + // Parse and validate additional origins from VALID_CORS_ORIGINS env var + const validOrigins = parseCorsAllowlist(process.env.VALID_CORS_ORIGINS, "VALID_CORS_ORIGINS"); + allowedOrigins.push(...validOrigins); + if (origin && allowedOrigins.includes(origin)) { event.response.headers.set("Access-Control-Allow-Origin", origin); event.response.headers.set("Access-Control-Allow-Credentials", "true"); diff --git a/web/src/server/api/routers/billing.test.ts b/web/src/server/api/routers/billing.test.ts index 8875d34..a0c633d 100644 --- a/web/src/server/api/routers/billing.test.ts +++ b/web/src/server/api/routers/billing.test.ts @@ -224,7 +224,7 @@ describe("billing.createCheckoutSession", () => { const api = createCaller(makeUser()); const result = await api.createCheckoutSession({ priceId: "price_basic", - returnUrl: "https://example.com/return", + returnUrl: "https://app.kordant.com/return", }) as { clientSecret: string; sessionId: string }; expect(result.clientSecret).toBe("cs_123_secret"); @@ -240,7 +240,7 @@ describe("billing.createCheckoutSession", () => { const api = createCaller(makeUser()); await api.createCheckoutSession({ priceId: "price_plus", - returnUrl: "https://example.com/return", + returnUrl: "https://app.kordant.com/return", }); expect(mockChangeSubscriptionTier).toHaveBeenCalledWith("sub_stripe_1", "price_plus"); @@ -257,7 +257,7 @@ describe("billing.createTrialSubscription", () => { const api = createCaller(makeUser()); const result = await api.createTrialSubscription({ - returnUrl: "https://example.com/return", + returnUrl: "https://app.kordant.com/return", }); expect(result.sessionId).toBe("session_trial"); @@ -270,7 +270,7 @@ describe("billing.createTrialSubscription", () => { const api = createCaller(makeUser()); await expect(api.createTrialSubscription({ - returnUrl: "https://example.com/return", + returnUrl: "https://app.kordant.com/return", })).rejects.toThrow(TRPCError); }); }); @@ -304,7 +304,7 @@ describe("billing.createPortalSession", () => { const api = createCaller(makeUser()); const result = await api.createPortalSession({ - returnUrl: "https://example.com/return", + returnUrl: "https://app.kordant.com/return", }); expect(result.url).toBe("https://billing.stripe.com/portal/session_456"); @@ -312,7 +312,7 @@ describe("billing.createPortalSession", () => { it("throws NOT_FOUND when user has no stripeCustomerId", async () => { const api = createCaller(makeUser({ stripeCustomerId: null })); - await expect(api.createPortalSession({ returnUrl: "https://example.com/return" })).rejects.toThrow(TRPCError); + await expect(api.createPortalSession({ returnUrl: "https://app.kordant.com/return" })).rejects.toThrow(TRPCError); }); }); diff --git a/web/src/server/api/utils.test.ts b/web/src/server/api/utils.test.ts index 35e1291..175b366 100644 --- a/web/src/server/api/utils.test.ts +++ b/web/src/server/api/utils.test.ts @@ -1,85 +1,273 @@ import { describe, it, expect } from "vitest"; /** - * Mirrors the SENSITIVE_PROCEDURES Set from utils.ts + * Mirrors the PROCEDURE_TIERS mapping from utils.ts + * + * Categories: + * sensitive (3/hr) — auth operations + * expensive (5/hr) — external API calls / ML inference + * memory (10/hr) — memory-heavy ML processing */ -const SENSITIVE_PROCEDURES = new Set([ - "user.login", - "user.signup", - "user.forgotPassword", - "user.resetPassword", - "darkwatch.runScan", - "darkwatch.runFullScan", - "voiceprint.analyzeAudio", - "voiceprint.createEnrollment", -]); +const PROCEDURE_TIERS: Record = { + // Auth operations — 3/hr + "user.login": "sensitive", + "user.signup": "sensitive", + "user.forgotPassword": "sensitive", + "user.resetPassword": "sensitive", -function getRateLimitTier(path: string, userRole: string | null, hasUser: boolean): "sensitive" | "authenticated" | "public" | "admin" { + // Darkwatch — 5/hr (expensive external API calls: HIBP, SecurityTrails, Censys, Shodan) + "darkwatch.runScan": "expensive", + "darkwatch.runFullScan": "expensive", + + // VoicePrint — 10/hr (ML analysis, 300MB+ memory per request) + "voiceprint.analyzeAudio": "memory", + "voiceprint.analyzeCallRecording": "memory", + "voiceprint.createEnrollment": "memory", + "voiceprint.enrollAdditionalSample": "memory", + + // SpamShield — 5/hr (ML model inference) + "spamshield.classifySMS": "expensive", + "spamshield.classifyCall": "expensive", + + // HomeTitle — 5/hr (county website scraping) + "hometitle.runScan": "expensive", + + // RemoveBrokers — 5/hr (broker website scraping) + "removebrokers.scanForListings": "expensive", +}; + +function getRateLimitTier( + path: string, + userRole: string | null, + hasUser: boolean, +): string { if (userRole === "admin") return "admin"; - if (SENSITIVE_PROCEDURES.has(path)) return "sensitive"; - return hasUser ? "authenticated" : "public"; + return PROCEDURE_TIERS[path] ?? (hasUser ? "authenticated" : "public"); } -describe("Rate limiter exact matching", () => { - describe("sensitive procedures", () => { - it("matches auth procedures", () => { +describe("Rate limiter tiered exact matching", () => { + // ----------------------------------------------------------------------- + // sensitive tier (3/hr) — auth operations + // ----------------------------------------------------------------------- + describe("sensitive tier — auth operations (3/hr)", () => { + it("matches user.login", () => { expect(getRateLimitTier("user.login", null, true)).toBe("sensitive"); + }); + + it("matches user.signup", () => { expect(getRateLimitTier("user.signup", null, true)).toBe("sensitive"); + }); + + it("matches user.forgotPassword", () => { expect(getRateLimitTier("user.forgotPassword", null, true)).toBe("sensitive"); + }); + + it("matches user.resetPassword", () => { expect(getRateLimitTier("user.resetPassword", null, true)).toBe("sensitive"); }); - - it("matches darkwatch procedures", () => { - expect(getRateLimitTier("darkwatch.runScan", null, true)).toBe("sensitive"); - expect(getRateLimitTier("darkwatch.runFullScan", null, true)).toBe("sensitive"); - }); - - it("matches voiceprint procedures", () => { - expect(getRateLimitTier("voiceprint.analyzeAudio", null, true)).toBe("sensitive"); - expect(getRateLimitTier("voiceprint.createEnrollment", null, true)).toBe("sensitive"); - }); }); - describe("non-sensitive procedures", () => { - it("returns authenticated tier for normal procedures", () => { + // ----------------------------------------------------------------------- + // expensive tier (5/hr) — external API calls / ML inference + // ----------------------------------------------------------------------- + describe("expensive tier — external API operations (5/hr)", () => { + it("matches darkwatch.runScan", () => { + expect(getRateLimitTier("darkwatch.runScan", null, true)).toBe("expensive"); + }); + + it("matches darkwatch.runFullScan", () => { + expect(getRateLimitTier("darkwatch.runFullScan", null, true)).toBe("expensive"); + }); + + it("matches spamshield.classifySMS", () => { + expect(getRateLimitTier("spamshield.classifySMS", null, true)).toBe("expensive"); + }); + + it("matches spamshield.classifyCall", () => { + expect(getRateLimitTier("spamshield.classifyCall", null, true)).toBe("expensive"); + }); + + it("matches hometitle.runScan", () => { + expect(getRateLimitTier("hometitle.runScan", null, true)).toBe("expensive"); + }); + + it("matches removebrokers.scanForListings", () => { + expect(getRateLimitTier("removebrokers.scanForListings", null, true)).toBe("expensive"); + }); + }); + + // ----------------------------------------------------------------------- + // memory tier (10/hr) — memory-heavy ML processing + // ----------------------------------------------------------------------- + describe("memory tier — ML analysis (10/hr)", () => { + it("matches voiceprint.analyzeAudio", () => { + expect(getRateLimitTier("voiceprint.analyzeAudio", null, true)).toBe("memory"); + }); + + it("matches voiceprint.analyzeCallRecording", () => { + expect(getRateLimitTier("voiceprint.analyzeCallRecording", null, true)).toBe("memory"); + }); + + it("matches voiceprint.createEnrollment", () => { + expect(getRateLimitTier("voiceprint.createEnrollment", null, true)).toBe("memory"); + }); + + it("matches voiceprint.enrollAdditionalSample", () => { + expect(getRateLimitTier("voiceprint.enrollAdditionalSample", null, true)).toBe("memory"); + }); + }); + + // ----------------------------------------------------------------------- + // Non-sensitive procedures — default tiers + // ----------------------------------------------------------------------- + describe("default tier fallback", () => { + it("returns authenticated tier for normal procedures when user is logged in", () => { expect(getRateLimitTier("blog.bySlug", null, true)).toBe("authenticated"); expect(getRateLimitTier("correlation.search", null, true)).toBe("authenticated"); - expect(getRateLimitTier("spamshield.analyze", null, true)).toBe("authenticated"); + expect(getRateLimitTier("spamshield.getRules", null, true)).toBe("authenticated"); + expect(getRateLimitTier("billing.getInvoices", null, true)).toBe("authenticated"); }); it("returns public tier for unauthenticated users", () => { expect(getRateLimitTier("blog.bySlug", null, false)).toBe("public"); + expect(getRateLimitTier("spamshield.modelInfo", null, false)).toBe("public"); }); it("returns admin tier for admin users regardless of procedure", () => { expect(getRateLimitTier("user.login", "admin", true)).toBe("admin"); expect(getRateLimitTier("darkwatch.runScan", "admin", true)).toBe("admin"); expect(getRateLimitTier("voiceprint.analyzeAudio", "admin", true)).toBe("admin"); + expect(getRateLimitTier("darkwatch.nonexistent", "admin", true)).toBe("admin"); }); }); + // ----------------------------------------------------------------------- + // Substring bypass prevention + // ----------------------------------------------------------------------- describe("substring bypass prevention", () => { - it("does not match substring attacks on auth procedures", () => { - // These should NOT be sensitive (substring match would incorrectly flag them) - expect(getRateLimitTier("user.loginLike", null, true)).toBe("authenticated"); - expect(getRateLimitTier("user.signupPage", null, true)).toBe("authenticated"); - expect(getRateLimitTier("user.loginResetPassword", null, true)).toBe("authenticated"); + describe("auth procedures", () => { + it("does not match login variant suffixes", () => { + expect(getRateLimitTier("user.loginLike", null, true)).toBe("authenticated"); + expect(getRateLimitTier("user.loginPage", null, true)).toBe("authenticated"); + expect(getRateLimitTier("user.logins", null, true)).toBe("authenticated"); + }); + + it("does not match signup variant suffixes", () => { + expect(getRateLimitTier("user.signupPage", null, true)).toBe("authenticated"); + expect(getRateLimitTier("user.signups", null, true)).toBe("authenticated"); + }); + + it("does not match concatenated procedure names", () => { + expect(getRateLimitTier("user.loginResetPassword", null, true)).toBe("authenticated"); + }); }); - it("does not match substring attacks on darkwatch", () => { - expect(getRateLimitTier("darkwatch.runScanLike", null, true)).toBe("authenticated"); - expect(getRateLimitTier("darkwatch.runScanHistory", null, true)).toBe("authenticated"); + describe("darkwatch procedures", () => { + it("does not match suffix attacks", () => { + expect(getRateLimitTier("darkwatch.runScanLike", null, true)).toBe("authenticated"); + expect(getRateLimitTier("darkwatch.runScanHistory", null, true)).toBe("authenticated"); + expect(getRateLimitTier("darkwatch.runScanner", null, true)).toBe("authenticated"); + }); + + it("does not match prefix attacks", () => { + expect(getRateLimitTier("notdarkwatch.runScan", null, true)).toBe("authenticated"); + expect(getRateLimitTier("predarkwatch.runScan", null, true)).toBe("authenticated"); + }); + + it("does not match different method on same namespace", () => { + expect(getRateLimitTier("darkwatch.notrunScan", null, true)).toBe("authenticated"); + expect(getRateLimitTier("darkwatch.getScanStatus", null, true)).toBe("authenticated"); + }); }); - it("does not match substring attacks on voiceprint", () => { - expect(getRateLimitTier("voiceprint.analyzeAudioPlayer", null, true)).toBe("authenticated"); - expect(getRateLimitTier("voiceprint.createEnrollmentPage", null, true)).toBe("authenticated"); + describe("voiceprint procedures", () => { + it("does not match suffix attacks", () => { + expect(getRateLimitTier("voiceprint.analyzeAudioPlayer", null, true)).toBe("authenticated"); + expect(getRateLimitTier("voiceprint.analyzeAudioFile", null, true)).toBe("authenticated"); + expect(getRateLimitTier("voiceprint.createEnrollmentPage", null, true)).toBe("authenticated"); + }); + + it("does not match partial path segments", () => { + expect(getRateLimitTier("voiceprint.analyze", null, true)).toBe("authenticated"); + expect(getRateLimitTier("voiceprint.create", null, true)).toBe("authenticated"); + expect(getRateLimitTier("voiceprint.enroll", null, true)).toBe("authenticated"); + }); }); - it("does not match partial path segments", () => { - expect(getRateLimitTier("notdarkwatch.runScan", null, true)).toBe("authenticated"); - expect(getRateLimitTier("darkwatch.notrunScan", null, true)).toBe("authenticated"); - expect(getRateLimitTier("voiceprint.analyze", null, true)).toBe("authenticated"); + describe("spamshield procedures", () => { + it("does not match suffix attacks", () => { + expect(getRateLimitTier("spamshield.classifySMSSpam", null, true)).toBe("authenticated"); + expect(getRateLimitTier("spamshield.classifyCallLog", null, true)).toBe("authenticated"); + }); + + it("does not match different method on same namespace", () => { + expect(getRateLimitTier("spamshield.getRules", null, true)).toBe("authenticated"); + expect(getRateLimitTier("spamshield.createRule", null, true)).toBe("authenticated"); + }); + }); + + describe("hometitle procedures", () => { + it("does not match suffix attacks", () => { + expect(getRateLimitTier("hometitle.runScanNow", null, true)).toBe("authenticated"); + expect(getRateLimitTier("hometitle.runScanner", null, true)).toBe("authenticated"); + }); + + it("does not match different method on same namespace", () => { + expect(getRateLimitTier("hometitle.getProperties", null, true)).toBe("authenticated"); + expect(getRateLimitTier("hometitle.addProperty", null, true)).toBe("authenticated"); + }); + }); + + describe("removebrokers procedures", () => { + it("does not match suffix attacks", () => { + expect(getRateLimitTier("removebrokers.scanForListingsNow", null, true)).toBe("authenticated"); + expect(getRateLimitTier("removebrokers.scanForListingsBatch", null, true)).toBe("authenticated"); + }); + + it("does not match different method on same namespace", () => { + expect(getRateLimitTier("removebrokers.getBrokerRegistry", null, true)).toBe("authenticated"); + expect(getRateLimitTier("removebrokers.createRemovalRequest", null, true)).toBe("authenticated"); + }); + }); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + describe("edge cases", () => { + it("handles empty path gracefully", () => { + expect(getRateLimitTier("", null, true)).toBe("authenticated"); + }); + + it("handles unknown paths gracefully", () => { + expect(getRateLimitTier("completely.unknown.procedure", null, true)).toBe("authenticated"); + }); + + it("handles paths with dots in unexpected places", () => { + expect(getRateLimitTier(".darkwatch.runScan", null, true)).toBe("authenticated"); + expect(getRateLimitTier("darkwatch..runScan", null, true)).toBe("authenticated"); + }); + }); + + // ----------------------------------------------------------------------- + // Tier configuration verification + // ----------------------------------------------------------------------- + describe("tier configuration", () => { + it("all mapped tiers are valid rate limit tier keys", () => { + const validTiers = new Set(["sensitive", "expensive", "memory"]); + for (const tier of Object.values(PROCEDURE_TIERS)) { + expect(validTiers.has(tier)).toBe(true); + } + }); + + it("every sensitive procedure has a defined tier", () => { + // If a procedure is listed, it must have a valid tier entry + const allMappedProcedures = Object.keys(PROCEDURE_TIERS); + expect(allMappedProcedures.length).toBeGreaterThan(0); + for (const proc of allMappedProcedures) { + expect(PROCEDURE_TIERS[proc]).toBeDefined(); + expect(["sensitive", "expensive", "memory"]).toContain(PROCEDURE_TIERS[proc]); + } }); }); }); diff --git a/web/src/server/api/utils.ts b/web/src/server/api/utils.ts index 020d49a..96a7304 100644 --- a/web/src/server/api/utils.ts +++ b/web/src/server/api/utils.ts @@ -32,23 +32,49 @@ const isAdmin = t.middleware(({ ctx, next }) => { export const adminProcedure = t.procedure.use(isAdmin); +/** + * Tiered procedure-to-rate-limit mapping. + * + * Exact procedure path matching (not substring) to prevent bypass. + * Categories: + * sensitive (3/hr) — auth operations + * expensive (5/hr) — external API calls / ML inference + * memory (10/hr) — memory-heavy ML processing + */ +const PROCEDURE_TIERS: Record = { + // Auth operations — 3/hr + "user.login": "sensitive", + "user.signup": "sensitive", + "user.forgotPassword": "sensitive", + "user.resetPassword": "sensitive", + + // Darkwatch — 5/hr (expensive external API calls: HIBP, SecurityTrails, Censys, Shodan) + "darkwatch.runScan": "expensive", + "darkwatch.runFullScan": "expensive", + + // VoicePrint — 10/hr (ML analysis, 300MB+ memory per request) + "voiceprint.analyzeAudio": "memory", + "voiceprint.analyzeCallRecording": "memory", + "voiceprint.createEnrollment": "memory", + "voiceprint.enrollAdditionalSample": "memory", + + // SpamShield — 5/hr (ML model inference) + "spamshield.classifySMS": "expensive", + "spamshield.classifyCall": "expensive", + + // HomeTitle — 5/hr (county website scraping) + "hometitle.runScan": "expensive", + + // RemoveBrokers — 5/hr (broker website scraping) + "removebrokers.scanForListings": "expensive", +}; + const isRateLimited = t.middleware(async ({ ctx, next, path }) => { const identifier = ctx.user?.id ?? ctx.apiKey ?? "anonymous"; const tier = ctx.user?.role === "admin" ? "admin" : ctx.user ? "authenticated" : "public"; - // Sensitive operations get stricter limits (exact match to prevent bypass) - const SENSITIVE_PROCEDURES = new Set([ - "user.login", - "user.signup", - "user.forgotPassword", - "user.resetPassword", - "darkwatch.runScan", - "darkwatch.runFullScan", - "voiceprint.analyzeAudio", - "voiceprint.createEnrollment", - ]); - - const effectiveTier = SENSITIVE_PROCEDURES.has(path) ? "sensitive" : tier; + // Look up procedure-specific tier, falling back to the default for the user + const effectiveTier = PROCEDURE_TIERS[path] ?? tier; await checkRateLimitOrThrow(identifier, effectiveTier); return next(); diff --git a/web/src/server/lib/cors-validation.ts b/web/src/server/lib/cors-validation.ts new file mode 100644 index 0000000..838ff3a --- /dev/null +++ b/web/src/server/lib/cors-validation.ts @@ -0,0 +1,49 @@ +/** + * Validates that an origin string is a well-formed HTTP(S) origin. + * Rejects wildcards, empty strings, non-HTTP schemes, and malformed URLs. + */ +export function validateCorsOrigin(origin: string): boolean { + if (!origin || !origin.trim()) return false; + if (origin === "*") return false; + + try { + const parsed = new URL(origin); + // Only allow http and https schemes + if (!parsed.protocol.match(/^https?:$/)) return false; + // Hostname must not be empty + if (!parsed.hostname) return false; + return true; + } catch { + return false; + } +} + +/** + * Parses a comma-separated origin allowlist string, validates each entry, + * and returns the subset of valid origins. Logs warnings for invalid entries. + * + * Accepts undefined/null to support optional env vars. + */ +export function parseCorsAllowlist( + raw: string | undefined | null, + label: string = "CORS_ORIGINS", +): string[] { + if (!raw || !raw.trim()) return []; + + const entries = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + const valid: string[] = []; + for (const entry of entries) { + if (validateCorsOrigin(entry)) { + valid.push(entry); + } else { + console.warn( + `[cors] ${label} entry "${entry}" is not a valid HTTP(S) origin and will be excluded`, + ); + } + } + return valid; +} diff --git a/web/src/server/lib/ratelimit.ts b/web/src/server/lib/ratelimit.ts index 1068ff0..a900d10 100644 --- a/web/src/server/lib/ratelimit.ts +++ b/web/src/server/lib/ratelimit.ts @@ -22,15 +22,19 @@ export type RateLimitTier = { windowMs: number; }; -export const rateLimitTiers: Record = { +export const rateLimitTiers = { public: { limit: 5, windowMs: 60_000 }, authenticated: { limit: 100, windowMs: 60_000 }, sensitive: { limit: 3, windowMs: 3_600_000 }, + /** Expensive external API operations: darkwatch scans, hometitle scans, spamshield ML */ + expensive: { limit: 5, windowMs: 3_600_000 }, + /** Memory-intensive ML operations: voiceprint analysis, enrollment */ + memory: { limit: 10, windowMs: 3_600_000 }, admin: { limit: 50, windowMs: 60_000 }, websocket: { limit: 1, windowMs: 60_000 }, websocketReconnect: { limit: 5, windowMs: 60_000 }, reputation: { limit: 100, windowMs: 60_000 }, -}; +} as const; export async function checkRateLimit( identifier: string, diff --git a/web/src/server/services/billing.service.ts b/web/src/server/services/billing.service.ts index 22c9195..905845c 100644 --- a/web/src/server/services/billing.service.ts +++ b/web/src/server/services/billing.service.ts @@ -235,16 +235,15 @@ export async function changeSubscriptionTier( // Update DB record const tier = mapStripeProductToTier(newPriceId); - const subData = updatedSub as unknown as Record; await updateSubscriptionInDB(stripeSubscriptionId, { tier, stripePriceId: newPriceId, - status: (subData.status as SubscriptionStatus) ?? "active", - currentPeriodStart: subData.current_period_start - ? new Date((subData.current_period_start as number) * 1000) + status: (updatedSub.status as SubscriptionStatus) ?? "active", + currentPeriodStart: updatedSub.current_period_start + ? new Date(updatedSub.current_period_start * 1000) : undefined, - currentPeriodEnd: subData.current_period_end - ? new Date((subData.current_period_end as number) * 1000) + currentPeriodEnd: updatedSub.current_period_end + ? new Date(updatedSub.current_period_end * 1000) : undefined, }); @@ -338,7 +337,6 @@ async function upsertSubscriptionFromStripe( userId: string, stripeSub: Stripe.Subscription, ) { - const subData = stripeSub as unknown as Record; const priceItem = stripeSub.items.data[0]?.price; const priceId = typeof priceItem === "string" @@ -350,17 +348,17 @@ async function upsertSubscriptionFromStripe( stripeId: stripeSub.id, stripePriceId: priceId || undefined, tier: mapStripeProductToTier(priceId), - status: (subData.status as SubscriptionStatus) ?? "active", - currentPeriodStart: subData.current_period_start - ? new Date((subData.current_period_start as number) * 1000) + status: (stripeSub.status as SubscriptionStatus) ?? "active", + currentPeriodStart: stripeSub.current_period_start + ? new Date(stripeSub.current_period_start * 1000) : undefined, - currentPeriodEnd: subData.current_period_end - ? new Date((subData.current_period_end as number) * 1000) + currentPeriodEnd: stripeSub.current_period_end + ? new Date(stripeSub.current_period_end * 1000) : undefined, - trialEnd: subData.trial_end - ? new Date((subData.trial_end as number) * 1000) + trialEnd: stripeSub.trial_end + ? new Date(stripeSub.trial_end * 1000) : undefined, - cancelAtPeriodEnd: Boolean(subData.cancel_at_period_end), + cancelAtPeriodEnd: stripeSub.cancel_at_period_end, }; // Upsert: insert or update if stripeId already exists @@ -386,9 +384,7 @@ async function extractPaymentMethodLast4( ): Promise { const defaultSource = stripeSub.default_payment_method; if (!defaultSource || typeof defaultSource === "string") return undefined; - const pm = defaultSource as Stripe.PaymentMethod; - if (pm.card?.last4) return pm.card.last4; - return undefined; + return defaultSource.card?.last4 ?? undefined; } export async function handleWebhookEvent(event: Stripe.Event) { diff --git a/web/src/server/services/reports/generator.test.ts b/web/src/server/services/reports/generator.test.ts index 4d62249..673396b 100644 --- a/web/src/server/services/reports/generator.test.ts +++ b/web/src/server/services/reports/generator.test.ts @@ -1,30 +1,9 @@ +// @vitest-environment node import { describe, it, expect } from "vitest"; +import { isBlockedUrl, generatePDF } from "./generator"; -/** - * URL blocking logic for SSRF protection. - * Mirrors the isBlockedUrl function in generator.ts. - */ -function isBlockedUrl(url: string): boolean { - if (url.startsWith("file:")) return true; - if (url.startsWith("data:")) return true; - if (/^https?:\/\/(169\.254\.169\.254|metadata\.google\.internal)/i.test(url)) return true; - - const hostname = url.replace(/^https?:\/\//, "").split(/[/:?]/)[0]; - if (/^(\d+\.\d+\.\d+\.\d+)/.test(hostname)) { - const [, ip] = hostname.match(/^(\d+\.\d+\.\d+\.\d+)/)!; - const parts = ip.split(".").map(Number); - if (parts[0] === 10) return true; - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; - if (parts[0] === 192 && parts[1] === 168) return true; - if (parts[0] === 127) return true; - if (parts[0] === 0) return true; - } - - return false; -} - -describe("SSRF URL blocking", () => { - describe("blocked URLs", () => { +describe("SSRF URL blocking (isBlockedUrl)", () => { + describe("blocked URL schemes", () => { it("blocks file:// URLs", () => { expect(isBlockedUrl("file:///etc/passwd")).toBe(true); expect(isBlockedUrl("file:///etc/shadow")).toBe(true); @@ -36,13 +15,66 @@ describe("SSRF URL blocking", () => { expect(isBlockedUrl("data:image/png;base64,abc")).toBe(true); }); - it("blocks cloud metadata endpoints", () => { + it("blocks chrome:// URLs", () => { + expect(isBlockedUrl("chrome://settings")).toBe(true); + expect(isBlockedUrl("chrome://version")).toBe(true); + expect(isBlockedUrl("chrome://net-internals")).toBe(true); + }); + + it("blocks about: URLs", () => { + expect(isBlockedUrl("about:blank")).toBe(true); + expect(isBlockedUrl("about:config")).toBe(true); + expect(isBlockedUrl("about:debugging")).toBe(true); + }); + + it("blocks ftp:// URLs", () => { + expect(isBlockedUrl("ftp://internal-server.secrets.com/data")).toBe(true); + }); + + it("blocks view-source: URLs", () => { + expect(isBlockedUrl("view-source:https://example.com")).toBe(true); + }); + + it("blocks javascript: URLs", () => { + expect(isBlockedUrl("javascript:alert(1)")).toBe(true); + }); + + it("blocks schemes case-insensitively", () => { + expect(isBlockedUrl("FILE:///etc/passwd")).toBe(true); + expect(isBlockedUrl("DATA:text/html,test")).toBe(true); + expect(isBlockedUrl("Chrome://settings")).toBe(true); + expect(isBlockedUrl("About:blank")).toBe(true); + }); + }); + + describe("blocked cloud metadata endpoints", () => { + it("blocks AWS metadata (169.254.169.254)", () => { expect(isBlockedUrl("http://169.254.169.254/latest/meta-data/")).toBe(true); expect(isBlockedUrl("https://169.254.169.254/computeMetadata/v1/")).toBe(true); + expect(isBlockedUrl("http://169.254.169.254/latest/api/token")).toBe(true); + }); + + it("blocks GCP metadata", () => { expect(isBlockedUrl("http://metadata.google.internal/computeMetadata/v1/")).toBe(true); expect(isBlockedUrl("https://metadata.google.internal/")).toBe(true); }); + it("blocks DigitalOcean metadata", () => { + expect(isBlockedUrl("http://169.254.170.2/v1.json")).toBe(true); + expect(isBlockedUrl("http://metadata.digitalocean.com/meta.json")).toBe(true); + }); + + it("blocks Oracle Cloud metadata", () => { + expect(isBlockedUrl("http://192.168.56.1/latest/ocids/")).toBe(true); + expect(isBlockedUrl("http://10.0.0.251/opc/")).toBe(true); + }); + + it("blocks Alibaba Cloud metadata", () => { + expect(isBlockedUrl("http://224.0.0.1/latest/meta-data/")).toBe(true); + }); + }); + + describe("blocked private IPv4 ranges", () => { it("blocks 10.0.0.0/8", () => { expect(isBlockedUrl("http://10.0.0.1/admin")).toBe(true); expect(isBlockedUrl("http://10.255.255.255/")).toBe(true); @@ -66,13 +98,49 @@ describe("SSRF URL blocking", () => { expect(isBlockedUrl("http://192.168.255.255/")).toBe(true); }); - it("blocks 127.0.0.0/8", () => { + it("blocks 127.0.0.0/8 (loopback)", () => { expect(isBlockedUrl("http://127.0.0.1:8080/health")).toBe(true); expect(isBlockedUrl("http://127.0.0.2/")).toBe(true); + expect(isBlockedUrl("http://127.255.255.255/")).toBe(true); }); - it("blocks 0.0.0.0", () => { + it("blocks 0.0.0.0/8", () => { expect(isBlockedUrl("http://0.0.0.0/")).toBe(true); + expect(isBlockedUrl("http://0.1.2.3/")).toBe(true); + }); + + it("blocks 169.254.0.0/16 (link-local)", () => { + expect(isBlockedUrl("http://169.254.1.1/")).toBe(true); + expect(isBlockedUrl("http://169.254.169.254/latest/meta-data/iam/security-credentials/")).toBe(true); + }); + }); + + describe("blocked bypass techniques", () => { + it("blocks integer IP notation", () => { + // 2130706433 = 127.0.0.1 + expect(isBlockedUrl("http://2130706433/")).toBe(true); + // 167772162 = 10.0.0.2 + expect(isBlockedUrl("http://167772162/")).toBe(true); + }); + + it("blocks octal IP notation", () => { + // 0177.0.0.1 = 127.0.0.1 + expect(isBlockedUrl("http://0177.0.0.1/")).toBe(true); + }); + + it("blocks IPv6 loopback", () => { + expect(isBlockedUrl("http://[::1]/")).toBe(true); + expect(isBlockedUrl("http://[::]/")).toBe(true); + }); + + it("blocks IPv6 private ranges", () => { + expect(isBlockedUrl("http://[fd00::1]/")).toBe(true); + expect(isBlockedUrl("http://[fe80::1]/")).toBe(true); + }); + + it("does not block URLs with IP-like path segments", () => { + expect(isBlockedUrl("https://example.com/192.168.1.1")).toBe(false); + expect(isBlockedUrl("https://cdn.example.com/path/10.0.0.1/image")).toBe(false); }); }); @@ -82,10 +150,7 @@ describe("SSRF URL blocking", () => { expect(isBlockedUrl("http://cdn.example.com/font.woff2")).toBe(false); expect(isBlockedUrl("https://fonts.googleapis.com/css")).toBe(false); expect(isBlockedUrl("https://app.kordant.com/api")).toBe(false); - }); - - it("does not block URLs with IP-like path segments", () => { - expect(isBlockedUrl("https://example.com/192.168.1.1")).toBe(false); + expect(isBlockedUrl("https://unpkg.com/lodash@4.17.21")).toBe(false); }); it("handles edge cases", () => { @@ -94,3 +159,74 @@ describe("SSRF URL blocking", () => { }); }); }); + +describe("generatePDF SSRF integration", () => { + describe("request interception configuration", () => { + it("generatePDF returns a Buffer", async () => { + expect(typeof generatePDF).toBe("function"); + const result = await generatePDF("

Test

"); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it("generatePDF returns a Buffer for legitimate HTML", async () => { + const html = ` + + + Test Report +

Test Report

This is a test.

+ + `; + const result = await generatePDF(html); + // In CI/test env without Chrome, falls back to HTML Buffer. + // In production with Chrome, returns valid PDF (%PDF- header). + expect(Buffer.isBuffer(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it("generatePDF handles HTML with embedded file:// URLs without crashing", async () => { + const html = ` + + + +

Test Report

+ blocked + blocked link + + + `; + // Should not throw — blocked URLs are aborted, not fatal + const result = await generatePDF(html); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it("generatePDF handles HTML with internal IP URLs without crashing", async () => { + const html = ` + + + +

Test Report

+ blocked + blocked + blocked + + + `; + const result = await generatePDF(html); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it("generatePDF handles HTML with data: URIs without crashing", async () => { + const html = ` + + + +

Test Report

+ blocked + + + `; + const result = await generatePDF(html); + expect(Buffer.isBuffer(result)).toBe(true); + }); + }); +}); diff --git a/web/src/server/services/reports/generator.ts b/web/src/server/services/reports/generator.ts index 4f3a9eb..564a27b 100644 --- a/web/src/server/services/reports/generator.ts +++ b/web/src/server/services/reports/generator.ts @@ -248,19 +248,85 @@ export function renderHTML(data: ReportData, reportType: string): string { /** * Returns true if the URL should be blocked (SSRF/metadata/internal access). + * + * Compensating control for --no-sandbox deployment: blocks all network requests + * to dangerous URL schemes, private IP ranges, and cloud metadata endpoints + * at the Puppeteer request-interception layer. */ export function isBlockedUrl(url: string): boolean { - // Block local file access - if (url.startsWith("file:")) return true; + const lower = url.toLowerCase(); - // Block data URIs - if (url.startsWith("data:")) return true; + // Block dangerous URL schemes + const blockedSchemes = ["file:", "data:", "chrome:", "about:", "ftp:", "view-source:", "javascript:"]; + for (const scheme of blockedSchemes) { + if (lower.startsWith(scheme)) return true; + } - // Block cloud metadata endpoints - if (/^https?:\/\/(169\.254\.169\.254|metadata\.google\.internal)/i.test(url)) return true; + // Block cloud metadata endpoints (AWS, GCP, Azure, DigitalOcean, Oracle, Alibaba) + const metadataPatterns = [ + /^https?:\/\/(169\.254\.169\.254|metadata\.google\.internal)/i, + /^https?:\/\/[a-z0-9.-]*\.(amazonaws\.com|amazontrust\.com)$/i, + /^https?:\/\/[a-z0-9.-]*\.(azure\.com|cloudapp\.azure\.com|management\.azure\.com)$/i, + /^https?:\/\/(169\.254\.169\.254|169\.254\.170\.2|metadata\.digitalocean\.com)/i, + /^https?:\/\/(192\.168\.56\.1|10\.0\.0\.251|curl\.169\.254\.169\.254)/i, + /^https?:\/\/(ueor\.com|aliyun\.internal)/i, + ]; + for (const pattern of metadataPatterns) { + if (pattern.test(url)) return true; + } - // Block internal/private IP ranges - const hostname = url.replace(/^https?:\/\//, "").split(/[/:?]/)[0]; + // Extract hostname for IP-based checks + // Handle IPv6 in brackets first: http://[::1]/path → [::1] + let hostname: string; + const ipv6Match = url.match(/^https?:\/\/\[([^\]]+)\]/i); + if (ipv6Match) { + hostname = `[${ipv6Match[1]}]`; + } else { + hostname = url.replace(/^https?:\/\//, "").split(/[/:?]/)[0]; + } + + // Handle IPv6 addresses in brackets [::1], [fd00::1], etc. + if (hostname.startsWith("[") && hostname.endsWith("]")) { + const ipv6Addr = hostname.slice(1, -1).toLowerCase(); + // Block IPv6 loopback + if (ipv6Addr === "::1" || ipv6Addr === "::") return true; + // Block IPv6 private ranges (fdxx::/8, fe80::/10) + if (/^(fd|fe80|fe8[0-9a-f]|fec|fed|fee|fef)/.test(ipv6Addr)) return true; + // Block IPv6 mapped IPv4 loopback ::ffff:127.x.x.x + if (ipv6Addr.startsWith("::ffff:")) { + const mapped = ipv6Addr.replace("::ffff:", ""); + if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/.test(mapped)) return true; + } + } + + // Block integer IP notation (e.g., 2130706433 = 127.0.0.1) + if (/^\d{7,}$/.test(hostname)) { + const intIp = parseInt(hostname, 10); + if (!isNaN(intIp) && intIp > 0) { + const parts = [ + (intIp >> 24) & 0xff, + (intIp >> 16) & 0xff, + (intIp >> 8) & 0xff, + intIp & 0xff, + ]; + // Apply same private IP checks to integer-decoded octets + if (parts[0] === 10) return true; + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + if (parts[0] === 192 && parts[1] === 168) return true; + if (parts[0] === 127) return true; + if (parts[0] === 0) return true; + if (parts[0] === 169 && parts[1] === 254) return true; + } + return true; // Block any large integer that looks like an IP + } + + // Block octal IP notation (e.g., 0177.0.0.1 = 127.0.0.1) + // Match IPs where any octet starts with 0 (possible octal) + if (/^0\d+\./.test(hostname)) { + return true; + } + + // Block internal/private IPv4 ranges if (/^(\d+\.\d+\.\d+\.\d+)/.test(hostname)) { const [, ip] = hostname.match(/^(\d+\.\d+\.\d+\.\d+)/)!; const parts = ip.split(".").map(Number); @@ -272,8 +338,12 @@ export function isBlockedUrl(url: string): boolean { if (parts[0] === 192 && parts[1] === 168) return true; // 127.0.0.0/8 (loopback) if (parts[0] === 127) return true; - // 0.0.0.0 + // 0.0.0.0/8 if (parts[0] === 0) return true; + // 169.254.0.0/16 (link-local / cloud metadata) + if (parts[0] === 169 && parts[1] === 254) return true; + // 224.0.0.0/4 (multicast, used by Alibaba Cloud metadata) + if (parts[0] >= 224 && parts[0] <= 239) return true; } return false; @@ -282,9 +352,29 @@ export function isBlockedUrl(url: string): boolean { export async function generatePDF(html: string): Promise { try { const puppeteer = await import("puppeteer"); - const browser = await puppeteer.launch({ headless: true, args: ["--no-sandbox"] }); + + /* + * SECURITY: --no-sandbox is required in Docker containers where Chrome + * cannot run as root with proper sandboxing. Compensating controls: + * 1. Request interception blocks all dangerous URL schemes and IPs + * 2. --disable-dev-shm-usage avoids /dev/shm race conditions + * 3. --disable-features=TrustTokens prevents token-based attacks + * 4. Container runs as non-root user (appuser) per Dockerfile + * 5. Network namespace isolation via Docker default networking + */ + const browser = await puppeteer.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-features=TrustTokens", + ], + }); const page = await browser.newPage(); + // Enable request interception (required in Puppeteer v22+) + await page.setRequestInterception(true); + // Block dangerous network requests to prevent SSRF page.on("request", (request) => { const url = request.url(); diff --git a/web/src/server/services/voiceprint.service.test.ts b/web/src/server/services/voiceprint.service.test.ts index 9eac0e5..e590586 100644 --- a/web/src/server/services/voiceprint.service.test.ts +++ b/web/src/server/services/voiceprint.service.test.ts @@ -31,6 +31,8 @@ vi.mock("./voiceprint/storage", () => ({ deleteFile: vi.fn(), computeHash: vi.fn(), deleteAudio: vi.fn(), + getUserStorageUsage: vi.fn(), + checkStorageQuota: vi.fn(), })); vi.mock("./voiceprint/ml.engine", () => ({ @@ -65,10 +67,15 @@ vi.mock("~/server/services/alert.publisher", () => ({ publishAlert: vi.fn(), })); +vi.mock("~/server/lib/ratelimit", () => ({ + checkRateLimitOrThrow: vi.fn(), +})); + const storage = await import("./voiceprint/storage"); const ml = await import("./voiceprint/ml.engine"); const azure = await import("./voiceprint/azure.client"); const tier = await import("~/server/lib/tier"); +const ratelimit = await import("~/server/lib/ratelimit"); const mockEnrollment = { id: "enr-1", @@ -158,10 +165,11 @@ const mockSub = { beforeEach(() => { vi.clearAllMocks(); - - // Default: user has plus tier with active subscription + // Completely reset the mock to avoid cross-test pollution + mockQueryResult.mockReset(); mockQueryResult.mockResolvedValue([mockSub]); vi.mocked(tier.hasFeatureAccess).mockReturnValue(true); + vi.mocked(ratelimit.checkRateLimitOrThrow).mockResolvedValue(undefined); }); afterEach(() => { @@ -189,9 +197,11 @@ describe("getEnrollments", () => { describe("createEnrollment", () => { it("saves audio, creates Azure profile, and stores enrollment", async () => { mockQueryResult.mockResolvedValueOnce([mockSub]); // subscription check + mockQueryResult.mockResolvedValueOnce([{ count: 0 }]); // enrollment count check vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash", filePath: "/path/file.wav", + isNew: true, }); vi.mocked(ml.preprocessAudio).mockResolvedValue({ duration: 2.5, @@ -260,6 +270,7 @@ describe("enrollAdditionalSample", () => { vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash-2", filePath: "/path/file2.wav", + isNew: true, }); vi.mocked(ml.preprocessAudio).mockResolvedValue({ duration: 2.0, @@ -346,6 +357,7 @@ describe("analyzeAudio", () => { vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash", filePath: "/path/file.wav", + isNew: true, }); vi.mocked(storage.getAudioUrl).mockReturnValue( "/uploads/voiceprint/user-1/audio-hash.wav", @@ -395,6 +407,7 @@ describe("analyzeAudio", () => { vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash", filePath: "/path/file.wav", + isNew: true, }); vi.mocked(storage.getAudioUrl).mockReturnValue( "/uploads/voiceprint/user-1/audio-hash.wav", @@ -442,6 +455,7 @@ describe("analyzeAudio", () => { vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash-synth", filePath: "/path/synth.wav", + isNew: true, }); vi.mocked(storage.getAudioUrl).mockReturnValue( "/uploads/voiceprint/user-1/audio-hash-synth.wav", @@ -634,9 +648,11 @@ describe("VoicePrint size limits", () => { it("accepts createEnrollment with valid-sized payload", async () => { mockQueryResult.mockResolvedValueOnce([mockSub]); // subscription check + mockQueryResult.mockResolvedValueOnce([{ count: 0 }]); // enrollment count check vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash", filePath: "/path/file.wav", + isNew: true, }); vi.mocked(ml.preprocessAudio).mockResolvedValue({ duration: 2.5, @@ -688,6 +704,7 @@ describe("VoicePrint size limits", () => { vi.mocked(storage.saveAudio).mockResolvedValue({ hash: "audio-hash", filePath: "/path/file.wav", + isNew: true, }); vi.mocked(storage.getAudioUrl).mockReturnValue( "/uploads/voiceprint/user-1/audio-hash.wav", @@ -744,3 +761,187 @@ describe("VoicePrint tier enforcement", () => { await expect(getEnrollments("user-1")).rejects.toThrow(TRPCError); }); }); + +describe("VoicePrint rate limiting", () => { + it("createEnrollment checks rate limit", async () => { + mockQueryResult.mockResolvedValueOnce([mockSub]); // subscription check + mockQueryResult.mockResolvedValueOnce([{ count: 0 }]); // enrollment count check + + const { createEnrollment } = await import("./voiceprint.service"); + await createEnrollment("user-1", "My Voice", "dGVzdA=="); + + expect(ratelimit.checkRateLimitOrThrow).toHaveBeenCalledWith("user-1", "memory"); + }); + + it("createEnrollment throws TOO_MANY_REQUESTS when rate limited", async () => { + mockQueryResult.mockResolvedValueOnce([mockSub]); // subscription check + mockQueryResult.mockResolvedValueOnce([{ count: 0 }]); // enrollment count check + vi.mocked(ratelimit.checkRateLimitOrThrow).mockRejectedValue( + new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Rate limit exceeded" }), + ); + + const { createEnrollment } = await import("./voiceprint.service"); + await expect(createEnrollment("user-1", "My Voice", "dGVzdA==")).rejects.toThrow( + "Rate limit exceeded", + ); + }); + + it("analyzeAudio checks rate limit", async () => { + mockQueryResult.mockResolvedValueOnce([mockSub]); // subscription check + mockQueryResult.mockResolvedValueOnce([{ count: 0 }]); // analysis limit check + + vi.mocked(storage.saveAudio).mockResolvedValue({ + hash: "audio-hash", + filePath: "/path/file.wav", + isNew: true, + }); + vi.mocked(storage.getAudioUrl).mockReturnValue("/uploads/voiceprint/user-1/audio-hash.wav"); + vi.mocked(ml.preprocessAudio).mockResolvedValue({ + duration: 2.5, + sampleRate: 16000, + channels: 1, + rawPcm: Buffer.from("test"), + snrEstimate: 30, + rmsEnergy: 0.3, + peakAmplitude: 0.8, + }); + vi.mocked(ml.detectSynthetic).mockResolvedValue({ + isSynthetic: false, + confidence: 0.95, + score: 0.05, + }); + vi.mocked(ml.deriveVerdict).mockReturnValue({ verdict: "NATURAL", isSynthetic: false }); + mockQueryResult.mockResolvedValueOnce([mockAnalysis]); + + const { analyzeAudio } = await import("./voiceprint.service"); + await analyzeAudio("user-1", "dGVzdA=="); + + expect(ratelimit.checkRateLimitOrThrow).toHaveBeenCalledWith("user-1", "memory"); + }); + + it("enrollAdditionalSample checks rate limit", async () => { + // Enrollment: subscription → find enrollment → update enrollment + mockQueryResult + .mockResolvedValueOnce([mockSub]) + .mockResolvedValueOnce([mockEnrollment]) + .mockResolvedValueOnce([mockEnrollment]) + .mockResolvedValue([mockSub]); // fallback + + vi.mocked(storage.saveAudio).mockResolvedValue({ + hash: "audio-hash-2", + filePath: "/path/file2.wav", + isNew: true, + }); + vi.mocked(ml.preprocessAudio).mockResolvedValue({ + duration: 2.0, + sampleRate: 16000, + channels: 1, + rawPcm: Buffer.from("more-audio"), + snrEstimate: 28, + rmsEnergy: 0.25, + peakAmplitude: 0.7, + }); + + const mockAzureClient = { + enrollProfile: vi.fn().mockResolvedValue({ + enrollmentStatus: "Enrolled", + enrollmentsCount: 3, + remainingEnrollments: 0, + phrase: "My voice is my passport", + audioLengthInSeconds: 2.0, + }), + }; + vi.mocked(azure.getAzureClient).mockReturnValue(mockAzureClient as any); + + const { enrollAdditionalSample } = await import("./voiceprint.service"); + await enrollAdditionalSample("user-1", "enr-1", "bW9yZS1hdWRpbw=="); + + expect(ratelimit.checkRateLimitOrThrow).toHaveBeenCalledWith("user-1", "memory"); + }); +}); + +describe("VoicePrint enrollment limits", () => { + it("rejects createEnrollment when max enrollments reached", async () => { + // Enrollment: subscription → enrollment count + mockQueryResult + .mockResolvedValueOnce([mockSub]) + .mockResolvedValueOnce([{ count: 5 }]) + .mockResolvedValue([mockSub]); // fallback + + const { createEnrollment } = await import("./voiceprint.service"); + await expect( + createEnrollment("user-1", "My Voice", "dGVzdA=="), + ).rejects.toThrow("Maximum 5 active enrollments reached"); + }); + + it("allows createEnrollment when under enrollment limit", async () => { + // Enrollment: subscription → enrollment count → insert enrollment + mockQueryResult + .mockResolvedValueOnce([mockSub]) + .mockResolvedValueOnce([{ count: 2 }]) + .mockResolvedValueOnce([mockEnrollment]) + .mockResolvedValue([mockSub]); // fallback + + vi.mocked(storage.saveAudio).mockResolvedValue({ + hash: "audio-hash", + filePath: "/path/file.wav", + isNew: true, + }); + vi.mocked(ml.preprocessAudio).mockResolvedValue({ + duration: 2.5, + sampleRate: 16000, + channels: 1, + rawPcm: Buffer.from("test"), + snrEstimate: 30, + rmsEnergy: 0.3, + peakAmplitude: 0.8, + }); + vi.mocked(ml.generateEmbedding).mockResolvedValue({ + vector: new Float64Array(256), + hash: "embed-hash", + }); + + const mockAzureClient = { + createProfile: vi.fn().mockResolvedValue({ + profileId: "azure-profile-123", + locale: "en-US", + enrollmentStatus: "Enrolling", + remainingEnrollments: 2, + createdDate: new Date().toISOString(), + }), + enrollProfile: vi.fn().mockResolvedValue({ + enrollmentStatus: "Enrolling", + enrollmentsCount: 1, + remainingEnrollments: 2, + phrase: "My voice is my passport", + audioLengthInSeconds: 2.5, + }), + }; + vi.mocked(azure.getAzureClient).mockReturnValue(mockAzureClient as any); + + const { createEnrollment } = await import("./voiceprint.service"); + const result = await createEnrollment("user-1", "My Voice", "dGVzdA=="); + expect(result).toEqual(mockEnrollment); + }); +}); + +describe("VoicePrint storage usage in stats", () => { + it("includes storage usage in getUsageStats", async () => { + // Stats: analyses count → enrollments count → call recordings count + mockQueryResult + .mockResolvedValueOnce([{ count: 5 }]) + .mockResolvedValueOnce([{ count: 2 }]) + .mockResolvedValueOnce([{ count: 3 }]) + .mockResolvedValue([mockSub]); // fallback + + vi.mocked(storage.getUserStorageUsage).mockResolvedValue(1048576); // 1MB + + const { getUsageStats } = await import("./voiceprint.service"); + const result = await getUsageStats("user-1"); + + expect(result.analysesThisMonth).toBe(5); + expect(result.activeEnrollments).toBe(2); + expect(result.storageUsedBytes).toBe(1048576); + expect(result.storageUsedMB).toBe(1); + }); +}); diff --git a/web/src/server/services/voiceprint.service.ts b/web/src/server/services/voiceprint.service.ts index bd41c3c..1d64a93 100644 --- a/web/src/server/services/voiceprint.service.ts +++ b/web/src/server/services/voiceprint.service.ts @@ -11,7 +11,7 @@ import { subscriptions, normalizedAlerts, } from "~/server/db/schema"; -import { saveAudio, getAudioUrl, deleteFile } from "./voiceprint/storage"; +import { saveAudio, getAudioUrl, deleteFile, getUserStorageUsage } from "./voiceprint/storage"; import { publishAlert } from "~/server/services/alert.publisher"; import { preprocessAudio, @@ -29,6 +29,7 @@ import { TIER_ORDER, type SubWithEffectiveTier, } from "~/server/lib/tier"; +import { checkRateLimitOrThrow } from "~/server/lib/ratelimit"; type DetectionVerdict = "NATURAL" | "SYNTHETIC" | "UNCERTAIN"; @@ -38,6 +39,12 @@ const MAX_DECODED_SIZE = parseInt( 10, ); +/** Maximum number of active voice enrollments per user (default 5). */ +const MAX_ENROLLMENTS = parseInt( + process.env.VOICEPRINT_MAX_ENROLLMENTS ?? "5", + 10, +); + /** US states requiring two-party consent for call recording. */ const TWO_PARTY_CONSENT_STATES = new Set([ "CA", "CT", "FL", "HI", "IL", "MD", "MA", "MI", "MT", "NH", "OR", "PA", "WA", @@ -177,9 +184,38 @@ export async function createEnrollment( audioBase64: string, ) { await checkVoicePrintAccess(userId); + + // Rate limit: memory-intensive enrollment operations + await checkRateLimitOrThrow(userId, "memory"); + + // Enforce enrollment count limit + const [enrollmentCountResult] = await db + .select({ count: count() }) + .from(voiceEnrollments) + .where( + and( + eq(voiceEnrollments.userId, userId), + eq(voiceEnrollments.isActive, true), + ), + ); + + if (enrollmentCountResult.count >= MAX_ENROLLMENTS) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Maximum ${MAX_ENROLLMENTS} active enrollments reached. Delete an existing enrollment to create a new one.`, + }); + } + validateDecodedSize(audioBase64); const audioBuffer = Buffer.from(audioBase64, "base64"); - const { hash: _hash, filePath } = await saveAudio(userId, audioBuffer); + + // Check for duplicate audio before expensive processing + const saved = await saveAudio(userId, audioBuffer); + + if (!saved.isNew) { + // Duplicate audio — still allow enrollment creation but skip re-saving + // (deduplication saves disk; enrollment can still use the same audio) + } // Preprocess audio to Azure-compatible format const features = await preprocessAudio(audioBuffer); @@ -220,7 +256,7 @@ export async function createEnrollment( azureEnrollmentStatus, enrollmentSampleCount, audioMetadata: { - filePath, + filePath: saved.filePath, duration: features.duration, sampleRate: features.sampleRate, azureProfileId, @@ -244,6 +280,10 @@ export async function enrollAdditionalSample( audioBase64: string, ) { await checkVoicePrintAccess(userId); + + // Rate limit: memory-intensive enrollment operations + await checkRateLimitOrThrow(userId, "memory"); + validateDecodedSize(audioBase64); const [enrollment] = await db @@ -425,10 +465,15 @@ export async function analyzeAudio( ) { await checkVoicePrintAccess(userId); await checkAnalysisLimit(userId); + + // Rate limit: memory-intensive analysis operations + await checkRateLimitOrThrow(userId, "memory"); + validateDecodedSize(audioBase64); const audioBuffer = Buffer.from(audioBase64, "base64"); - const { hash: audioHash } = await saveAudio(userId, audioBuffer); + const saved = await saveAudio(userId, audioBuffer); + const audioHash = saved.hash; // Preprocess audio for Azure const features = await preprocessAudio(audioBuffer); @@ -677,10 +722,15 @@ export async function getUsageStats(userId: string) { ), ); + // Get disk storage usage + const storageUsage = await getUserStorageUsage(userId); + return { analysesThisMonth: analysisCount.count, activeEnrollments: enrollmentCount.count, callRecordingsThisMonth: callRecordingsCount.count, + storageUsedBytes: storageUsage, + storageUsedMB: Math.round(storageUsage / 1024 / 1024 * 100) / 100, }; } @@ -800,6 +850,9 @@ export async function analyzeCallRecording( ) { await checkVoicePrintAccess(userId); + // Rate limit: memory-intensive call recording analysis + await checkRateLimitOrThrow(userId, "memory"); + let audioHash: string | undefined; let audioFilePath: string | undefined; diff --git a/web/src/server/services/voiceprint/audio.processor.test.ts b/web/src/server/services/voiceprint/audio.processor.test.ts index 621a7b0..10aff6c 100644 --- a/web/src/server/services/voiceprint/audio.processor.test.ts +++ b/web/src/server/services/voiceprint/audio.processor.test.ts @@ -221,5 +221,30 @@ describe("Audio Processor", () => { expect(result.pcmBuffer.length).toBe(result.samples.length * 2); expect(result.samples.BYTES_PER_ELEMENT).toBe(2); }); + + it("rejects oversized input files to prevent memory exhaustion", async () => { + // Create a valid WAV that is larger than the default 5MB limit + // 44100Hz * 2ch * 2bytes * duration = bytes. For 5MB: duration > 5*1024*1024 / (44100*2*2) ≈ 29.1s + const oversizedWav = createTestWav(44100, 2, 16, 30); + await expect(preprocessAudio(oversizedWav)).rejects.toThrow( + "Audio file too large", + ); + }); + + it("rejects extremely long audio from header before full decode", async () => { + // Create a WAV with a 60-second duration (exceeds 30s + 30s grace = 60s) + const longWav = createTestWav(16000, 1, 16, 65); + await expect(preprocessAudio(longWav)).rejects.toThrow( + "Audio too long", + ); + }); + + it("allows audio just under the duration limit", async () => { + // 35 seconds should be fine (30s max + 30s grace = 60s total header check) + const wavBuffer = createTestWav(16000, 1, 16, 35); + const result = await preprocessAudio(wavBuffer); + // Output is capped at 30s by the post-processing limiter + expect(result.duration).toBeLessThanOrEqual(30); + }); }); }); diff --git a/web/src/server/services/voiceprint/audio.processor.ts b/web/src/server/services/voiceprint/audio.processor.ts index cbe0914..f6a6707 100644 --- a/web/src/server/services/voiceprint/audio.processor.ts +++ b/web/src/server/services/voiceprint/audio.processor.ts @@ -41,6 +41,11 @@ interface WavHeader { /** Maximum allowed audio duration in seconds */ const MAX_DURATION_SEC = 30; +/** Maximum raw WAV file size before processing (default 5MB). Prevents memory exhaustion. */ +const MAX_INPUT_BYTES = parseInt( + process.env.VOICEPRINT_MAX_INPUT_BYTES ?? "5242880", + 10, +); /** Target normalization level in dBFS */ const TARGET_DBFS = -3; /** Frame size for VAD in milliseconds */ @@ -359,16 +364,27 @@ function computeQualityMetrics(samples: Float64Array): { /** * Main audio preprocessing pipeline: - * 1. Parse WAV header - * 2. Read PCM samples - * 3. Convert to mono - * 4. Resample to 16kHz - * 5. Normalize to -3 dBFS - * 6. VAD silence trimming - * 7. Limit to 30 seconds - * 8. Convert to 16-bit PCM + * 1. Validate input size + * 2. Parse WAV header + * 3. Validate duration from header (reject too-long audio before decoding) + * 4. Read PCM samples + * 5. Convert to mono + * 6. Resample to 16kHz + * 7. Normalize to -3 dBFS + * 8. VAD silence trimming + * 9. Limit to 30 seconds + * 10. Convert to 16-bit PCM */ export async function preprocessAudio(inputBuffer: Buffer): Promise { + // Reject oversized input early to prevent memory exhaustion + if (inputBuffer.length > MAX_INPUT_BYTES) { + throw new Error( + `Audio file too large: ${(inputBuffer.length / 1024 / 1024).toFixed(1)}MB. ` + + `Maximum ${(MAX_INPUT_BYTES / 1024 / 1024).toFixed(0)}MB. ` + + `Please upload a shorter audio clip (max ${MAX_DURATION_SEC} seconds).`, + ); + } + // Detect if it's a WAV by checking RIFF header const isWav = inputBuffer.length >= 4 && @@ -382,6 +398,17 @@ export async function preprocessAudio(inputBuffer: Buffer): Promise MAX_DURATION_SEC + 30) { + throw new Error( + `Audio too long: ${durationSec.toFixed(1)}s. Maximum ${MAX_DURATION_SEC}s for analysis. ` + + `Please trim your audio before uploading.`, + ); + } let samples = readPcmSamples(inputBuffer, header, dataOffset); // Convert to mono diff --git a/web/src/server/services/voiceprint/storage.test.ts b/web/src/server/services/voiceprint/storage.test.ts index bc9153f..a2c3a0e 100644 --- a/web/src/server/services/voiceprint/storage.test.ts +++ b/web/src/server/services/voiceprint/storage.test.ts @@ -12,10 +12,13 @@ describe("voiceprint storage", () => { testDir = mkdtempSync(join(tmpdir(), "vp-storage-test-")); userId = "test-user-123"; vi.spyOn(process, "cwd").mockReturnValue(testDir); + // Set a small quota for testing + vi.stubEnv("VOICEPRINT_MAX_USER_STORAGE_BYTES", "1024"); // 1KB for tests }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); try { rmSync(testDir, { recursive: true, force: true }); } catch { @@ -42,6 +45,7 @@ describe("voiceprint storage", () => { expect(result.hash.length).toBe(64); expect(result.filePath).toContain(userId); expect(existsSync(result.filePath)).toBe(true); + expect(result.isNew).toBe(true); }); it("reuses existing directory", async () => { @@ -52,6 +56,69 @@ describe("voiceprint storage", () => { const dir = join(testDir, "uploads", "voiceprint", userId); expect(existsSync(dir)).toBe(true); }); + + it("deduplicates identical audio (returns isNew: false)", async () => { + const { saveAudio } = await import("./storage"); + const audioBuffer = Buffer.from("same-audio-content"); + + const first = await saveAudio(userId, audioBuffer); + expect(first.isNew).toBe(true); + + const second = await saveAudio(userId, audioBuffer); + expect(second.isNew).toBe(false); + expect(second.hash).toBe(first.hash); + expect(second.filePath).toBe(first.filePath); + }); + + it("throws when storage quota would be exceeded", async () => { + const { saveAudio } = await import("./storage"); + // Quota is 1KB, upload 2KB + const largeBuffer = Buffer.alloc(2048, "A"); + await expect(saveAudio(userId, largeBuffer)).rejects.toThrow("Storage quota exceeded"); + }); + + it("allows upload under quota", async () => { + const { saveAudio } = await import("./storage"); + // Quota is 1KB, upload 100 bytes + const smallBuffer = Buffer.alloc(100, "B"); + const result = await saveAudio(userId, smallBuffer); + expect(result.isNew).toBe(true); + expect(existsSync(result.filePath)).toBe(true); + }); + }); + + describe("getUserStorageUsage", () => { + it("returns 0 for non-existent user directory", async () => { + const { getUserStorageUsage } = await import("./storage"); + const usage = await getUserStorageUsage("nonexistent-user"); + expect(usage).toBe(0); + }); + + it("returns total bytes of all audio files", async () => { + const { saveAudio, getUserStorageUsage } = await import("./storage"); + + // Save two files + await saveAudio(userId, Buffer.from("file-one-content")); + await saveAudio(userId, Buffer.from("file-two-content")); + + const usage = await getUserStorageUsage(userId); + expect(usage).toBeGreaterThan(0); + // Should be exactly the sum of the two file sizes + expect(usage).toBe("file-one-content".length + "file-two-content".length); + }); + + it("deduplication means second identical upload doesn\'t increase usage", async () => { + const { saveAudio, getUserStorageUsage } = await import("./storage"); + const audioBuffer = Buffer.from("identical-content-for-usage-test"); + + await saveAudio(userId, audioBuffer); + const usageAfterFirst = await getUserStorageUsage(userId); + + await saveAudio(userId, audioBuffer); + const usageAfterSecond = await getUserStorageUsage(userId); + + expect(usageAfterFirst).toBe(usageAfterSecond); + }); }); describe("getAudioUrl", () => { diff --git a/web/src/server/services/voiceprint/storage.ts b/web/src/server/services/voiceprint/storage.ts index c4e7483..f1a1091 100644 --- a/web/src/server/services/voiceprint/storage.ts +++ b/web/src/server/services/voiceprint/storage.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { writeFile, unlink, mkdir } from "node:fs/promises"; +import { writeFile, unlink, mkdir, stat, readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; @@ -11,18 +11,89 @@ function getUserDir(userId: string): string { return join(process.cwd(), "uploads", "voiceprint", userId); } +/** + * Maximum total disk storage per user (default 50MB). + * Prevents disk exhaustion via unlimited audio uploads. + */ +const MAX_USER_STORAGE_BYTES = parseInt( + process.env.VOICEPRINT_MAX_USER_STORAGE_BYTES ?? "52428800", + 10, +); + +/** + * Calculate total disk usage for a user's voiceprint audio files. + */ +export async function getUserStorageUsage(userId: string): Promise { + const userDir = getUserDir(userId); + if (!existsSync(userDir)) return 0; + + const files = await readdir(userDir); + let totalBytes = 0; + + for (const file of files) { + const filePath = join(userDir, file); + try { + const stats = await stat(filePath); + if (stats.isFile()) { + totalBytes += stats.size; + } + } catch { + // Skip files we can't stat + } + } + + return totalBytes; +} + +/** + * Check if saving a file of the given size would exceed the user's storage quota. + * Throws if the quota would be exceeded. + */ +export async function checkStorageQuota( + userId: string, + fileSizeBytes: number, +): Promise { + const currentUsage = await getUserStorageUsage(userId); + const projectedUsage = currentUsage + fileSizeBytes; + + if (projectedUsage > MAX_USER_STORAGE_BYTES) { + throw new Error( + `Storage quota exceeded. User has ${(currentUsage / 1024 / 1024).toFixed(1)}MB ` + + `of ${(MAX_USER_STORAGE_BYTES / 1024 / 1024).toFixed(0)}MB allocated. ` + + `Upload is ${(fileSizeBytes / 1024).toFixed(0)}KB. Delete old audio files to free space.`, + ); + } +} + +/** + * Save audio file with deduplication. If a file with the same hash already exists, + * skip writing and return the existing path. This prevents redundant storage and + * avoids re-processing identical audio. + * + * @returns { hash, filePath, isNew } — isNew is false if the file already existed. + */ export async function saveAudio( userId: string, audioBuffer: Buffer, -): Promise<{ hash: string; filePath: string }> { +): Promise<{ hash: string; filePath: string; isNew: boolean }> { const hash = computeHash(audioBuffer); const userDir = getUserDir(userId); + const filePath = join(userDir, `${hash}.wav`); + + // Deduplication: if the file already exists, skip writing + if (existsSync(filePath)) { + return { hash, filePath, isNew: false }; + } + + // Check storage quota before writing + await checkStorageQuota(userId, audioBuffer.length); + if (!existsSync(userDir)) { await mkdir(userDir, { recursive: true }); } - const filePath = join(userDir, `${hash}.wav`); + await writeFile(filePath, audioBuffer); - return { hash, filePath }; + return { hash, filePath, isNew: true }; } export function getAudioUrl(userId: string, audioHash: string): string {