web security audit fixes

This commit is contained in:
2026-06-02 10:30:42 -04:00
parent 36b087ae92
commit ab0d4857db
26 changed files with 1527 additions and 289 deletions

View File

@@ -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=""

127
bun.lock
View File

@@ -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=="],

View File

@@ -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`

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

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

View File

@@ -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<string>(
(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.",
);

View File

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

View File

@@ -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<typeof setInterval> | null = null;
let pongTimer: ReturnType<typeof setTimeout> | null = null;
let intentionalClose = false;
let isAuthenticated = false;
let authTimer: ReturnType<typeof setTimeout> | 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;

View File

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

View File

@@ -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");

View File

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

View File

@@ -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<string, string> = {
// 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]);
}
});
});
});

View File

@@ -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<string, keyof typeof import("~/server/lib/ratelimit").rateLimitTiers> = {
// 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();

View File

@@ -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;
}

View File

@@ -22,15 +22,19 @@ export type RateLimitTier = {
windowMs: number;
};
export const rateLimitTiers: Record<string, RateLimitTier> = {
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,

View File

@@ -235,16 +235,15 @@ export async function changeSubscriptionTier(
// Update DB record
const tier = mapStripeProductToTier(newPriceId);
const subData = updatedSub as unknown as Record<string, unknown>;
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<string, unknown>;
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<string | undefined> {
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) {

View File

@@ -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("<html><body><h1>Test</h1></body></html>");
expect(Buffer.isBuffer(result)).toBe(true);
});
it("generatePDF returns a Buffer for legitimate HTML", async () => {
const html = `
<!DOCTYPE html>
<html>
<head><title>Test Report</title></head>
<body><h1>Test Report</h1><p>This is a test.</p></body>
</html>
`;
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 = `
<!DOCTYPE html>
<html>
<body>
<h1>Test Report</h1>
<img src="file:///etc/passwd" alt="blocked" />
<a href="file:///etc/shadow">blocked link</a>
</body>
</html>
`;
// 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 = `
<!DOCTYPE html>
<html>
<body>
<h1>Test Report</h1>
<img src="http://169.254.169.254/latest/meta-data/" alt="blocked" />
<img src="http://10.0.0.1/admin" alt="blocked" />
<img src="http://127.0.0.1:8080/health" alt="blocked" />
</body>
</html>
`;
const result = await generatePDF(html);
expect(Buffer.isBuffer(result)).toBe(true);
});
it("generatePDF handles HTML with data: URIs without crashing", async () => {
const html = `
<!DOCTYPE html>
<html>
<body>
<h1>Test Report</h1>
<img src="data:text/html,<script>alert(1)</script>" alt="blocked" />
</body>
</html>
`;
const result = await generatePDF(html);
expect(Buffer.isBuffer(result)).toBe(true);
});
});
});

View File

@@ -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<Buffer> {
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();

View File

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

View File

@@ -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;

View File

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

View File

@@ -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<ProcessedAudio> {
// 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<ProcessedAud
}
const { header, dataOffset } = parseWavHeader(inputBuffer);
// Validate duration from header BEFORE allocating sample buffers.
// This prevents loading multi-hour WAV files into memory.
const totalSamples = Math.floor(header.dataSize / (header.bitsPerSample / 8) / header.numChannels);
const durationSec = totalSamples / header.sampleRate;
if (durationSec > 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

View File

@@ -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", () => {

View File

@@ -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<number> {
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<void> {
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 {