diff --git a/backend/package-lock.json b/backend/package-lock.json index 3ed9fcbd..b18768d4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "dependencies": { "@fastify/cors": "^11.0.1", "@fastify/rate-limit": "^10.3.0", @@ -18,7 +18,7 @@ "gray-matter": "^4.0.3", "markdown-it": "^14.1.0", "openai": "^4.96.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "tar": "^7.5.15", "undici": "^8.2.0", "uuid": "^14.0.0", @@ -553,9 +553,9 @@ "license": "MIT" }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -1210,9 +1210,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1222,19 +1222,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1244,19 +1244,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1270,9 +1289,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1286,12 +1305,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1302,12 +1324,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1318,12 +1343,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1334,12 +1362,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1350,12 +1381,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1366,12 +1400,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1382,12 +1419,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1398,12 +1438,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1414,204 +1457,244 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1621,16 +1704,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1640,16 +1723,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1659,7 +1742,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2575,9 +2658,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3367,9 +3450,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -3464,9 +3547,9 @@ } }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3492,16 +3575,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3766,9 +3849,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4604,9 +4687,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -4748,9 +4831,19 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -5643,9 +5736,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5661,47 +5754,52 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -5935,9 +6033,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -6171,9 +6269,9 @@ } }, "node_modules/undici": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.2.0.tgz", - "integrity": "sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/backend/package.json b/backend/package.json index 6074ed19..7a8e66f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.16", + "version": "2.0.0", "description": "Papyrus Desktop TypeScript backend", "type": "module", "main": "dist/api/server.js", @@ -23,7 +23,7 @@ "gray-matter": "^4.0.3", "markdown-it": "^14.1.0", "openai": "^4.96.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "tar": "^7.5.15", "undici": "^8.2.0", "uuid": "^14.0.0", diff --git a/backend/src/ai/provider.ts b/backend/src/ai/provider.ts index f0b847de..06a9425a 100644 --- a/backend/src/ai/provider.ts +++ b/backend/src/ai/provider.ts @@ -1457,7 +1457,10 @@ Output only the translation, no explanations.`; } delete headers['content-length']; - return fetchWithProxy(reqUrl, { ...reqInit, headers } as unknown as RequestInit) as unknown as ReturnType; + // 本地 Provider(Ollama/LM Studio 等)必须允许回环地址,其余 Provider 一律走禁私网的默认 Agent。 + return fetchWithProxy(reqUrl, { ...reqInit, headers } as unknown as RequestInit, { + allowLoopback: isLocalProvider, + }) as unknown as ReturnType; }) as Fetch, }); @@ -1589,7 +1592,7 @@ Output only the translation, no explanations.`; ? new PapyrusTools().getToolsForOpenAI(allowedToolNames) : undefined; - const response = await fetch(`${baseUrl}/api/chat`, { + const response = await fetchWithProxy(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: signal ?? AbortSignal.timeout(60000), @@ -1603,7 +1606,7 @@ Output only the translation, no explanations.`; }, ...(ollamaTools && ollamaTools.length > 0 ? { tools: ollamaTools } : {}), }), - }); + }, { allowLoopback: true }); if (!response.ok) { throw new Error(`Ollama API 错误: ${response.status} ${response.statusText}`); diff --git a/backend/src/api/routes/ai-completion.ts b/backend/src/api/routes/ai-completion.ts index 2e6dda6d..3d9223ad 100644 --- a/backend/src/api/routes/ai-completion.ts +++ b/backend/src/api/routes/ai-completion.ts @@ -142,7 +142,7 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom return; } - const resp = await fetch(`${baseUrl}/api/chat`, { + const resp = await fetchWithProxy(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(60000), @@ -155,7 +155,7 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom stream: true, options: { temperature: 0.7 }, }), - }); + }, { allowLoopback: true }); if (!resp.ok || !resp.body) { reply.raw.write(`data: {"error":"Ollama API 错误: ${resp.status}"}\n\n`); @@ -231,12 +231,13 @@ export default async function aiCompletionRoutes(fastify: FastifyInstance): Prom const headers: Record = { 'Content-Type': 'application/json' }; if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + // keyless 本地 Provider(LM Studio 等)的 base URL 是 localhost,需要回环放行;其余走默认禁私网 Agent。 const resp = await fetchWithProxy(endpoint, { method: 'POST', headers, signal: AbortSignal.timeout(60000), body: JSON.stringify(reqBody), - }); + }, { allowLoopback: isKeylessProvider(providerName) }); if (!resp.ok || !resp.body) { reply.raw.write(`data: {"error":"API 错误: ${resp.status}"}\n\n`); diff --git a/backend/src/api/routes/ai-config.ts b/backend/src/api/routes/ai-config.ts index b4c02729..bc8b2c3c 100644 --- a/backend/src/api/routes/ai-config.ts +++ b/backend/src/api/routes/ai-config.ts @@ -123,10 +123,11 @@ export default async function aiConfigRoutes(fastify: FastifyInstance): Promise< try { // 对于 keyless providers,尝试连接 base URL // 不同的 provider 可能有不同的健康检查端点,这里我们做一个简单的 GET 请求 - const resp = await fetch(baseUrl, { - method: 'GET', - signal: AbortSignal.timeout(5000) - }); + // keyless Provider 只允许 localhost/127.0.0.1(validateProviderBaseUrl 已保证),故放行回环。 + const resp = await fetchWithProxy(baseUrl, { + method: 'GET', + signal: AbortSignal.timeout(5000) + }, { allowLoopback: true }); if (resp.ok || resp.status === 404 || resp.status === 401) { // 即使返回 404 或 401,也说明服务器是可访问的 reply.send({ success: true, message: `${providerName} 连接成功` }); diff --git a/backend/src/api/routes/cli.ts b/backend/src/api/routes/cli.ts index feab0a6c..8c5b0e2f 100644 --- a/backend/src/api/routes/cli.ts +++ b/backend/src/api/routes/cli.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { defaultCliManager } from '#/cli/cli-manager.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; interface CliRunPayload { args?: unknown; @@ -22,7 +23,7 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 状态检查失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 状态检查失败') }); } }); @@ -32,7 +33,7 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 安装失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 安装失败') }); } }); @@ -42,18 +43,28 @@ export default async function cliRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : 'CLI 更新失败'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'CLI 更新失败') }); } }); fastify.post('/run', async (request, reply) => { + let args: string[]; + try { + args = readCliArgs(request.body as CliRunPayload | undefined); + } catch (e) { + // 入参校验错误面向调用方(描述请求体问题),保留原文便于修正请求; + // 与内部执行错误区分开,后者才需要 debug 门控消毒。 + const message = e instanceof Error ? e.message : 'CLI 运行失败'; + request.log.warn({ err: e }, message); + reply.status(400).send({ success: false, error: message }); + return; + } try { - const args = readCliArgs(request.body as CliRunPayload | undefined); reply.send(await defaultCliManager.run(args)); } catch (err) { const message = err instanceof Error ? err.message : 'CLI 运行失败'; request.log.warn({ err }, message); - reply.status(400).send({ success: false, error: message }); + reply.status(400).send({ success: false, error: routeErrorMessage(err, 'CLI 运行失败') }); } }); } diff --git a/backend/src/api/routes/data.ts b/backend/src/api/routes/data.ts index 04b37b8d..980b6733 100644 --- a/backend/src/api/routes/data.ts +++ b/backend/src/api/routes/data.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { paths } from '../../utils/paths.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; import { loadAllCards, insertCard, checkpointDb, runInTransaction, loadAllNotes, insertNote, @@ -26,19 +27,21 @@ export default async function dataRoutes(fastify: FastifyInstance): Promise { + fastify.post('/data/reset', async (request, reply) => { try { clearAllData(); checkpointDb(); reply.send({ success: true }); } catch (err) { + // 重置是破坏性操作,失败原因完整记录在服务端日志;响应不回显内部细节。 + request.log.error({ err }, '数据重置失败'); reply.status(500).send({ success: false, - error: err instanceof Error ? err.message : 'Failed to reset data', + error: routeErrorMessage(err, '数据重置失败'), }); } }); @@ -56,7 +59,7 @@ export default async function dataRoutes(fastify: FastifyInstance): Promise { return { @@ -40,7 +41,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -62,7 +63,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -115,7 +116,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -131,7 +132,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -153,7 +154,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -172,7 +173,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -188,7 +189,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -233,7 +234,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -260,7 +261,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); @@ -304,7 +305,7 @@ export default async function mcpRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, 'MCP 接口处理失败') }); } }); } diff --git a/backend/src/api/routes/providers.ts b/backend/src/api/routes/providers.ts index ee72743f..da1bc9a7 100644 --- a/backend/src/api/routes/providers.ts +++ b/backend/src/api/routes/providers.ts @@ -18,6 +18,7 @@ import type { Provider } from '../../core/types.js'; import { aiConfig } from '../../ai/config-instance.js'; import { loadAIConfigFromDb } from '../../ai/db-sync.js'; import { validateProviderBaseUrl, isMaskedApiKeySubmission } from '../../utils/provider-security.js'; +import { routeErrorMessage } from '../../utils/route-error.js'; const ApiKeySchema = z.object({ id: z.string().optional(), @@ -63,7 +64,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -107,7 +108,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise reply.status(409).send({ success: false, error: '相同配置的服务商已存在' }); return; } - reply.status(500).send({ success: false, error: `添加供应商失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '添加供应商失败') }); } }); @@ -171,7 +172,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise reply.status(409).send({ success: false, error: '相同配置的服务商已存在' }); return; } - reply.status(500).send({ success: false, error: `更新供应商失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '更新供应商失败') }); } }); @@ -210,7 +211,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -224,7 +225,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -254,7 +255,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -276,10 +277,11 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise return; } if (msg.includes('FOREIGN KEY')) { - reply.status(400).send({ success: false, error: `添加模型失败:外键约束失败,apiKeyId 或 providerId 不存在 (${msg})` }); + // 外键约束属于可操作的输入错误,但原始 msg 可能带表名/索引等内部细节,仅提示语义原因。 + reply.status(400).send({ success: false, error: '添加模型失败: 外键约束失败,apiKeyId 或 providerId 不存在' }); return; } - reply.status(500).send({ success: false, error: `添加模型失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '添加模型失败') }); } }); @@ -332,10 +334,10 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('FOREIGN KEY')) { - reply.status(400).send({ success: false, error: `更新模型失败:外键约束失败,apiKeyId 或 providerId 不存在 (${msg})` }); + reply.status(400).send({ success: false, error: '更新模型失败: 外键约束失败,apiKeyId 或 providerId 不存在' }); return; } - reply.status(500).send({ success: false, error: `更新模型失败: ${msg}` }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '更新模型失败') }); } }); @@ -384,7 +386,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -402,7 +404,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); @@ -414,7 +416,7 @@ export default async function providersRoutes(fastify: FastifyInstance): Promise } catch (err) { const message = err instanceof Error ? err.message : '服务器内部错误'; request.log.error({ err }, message); - reply.status(500).send({ success: false, error: message }); + reply.status(500).send({ success: false, error: routeErrorMessage(err, '供应商配置操作失败') }); } }); } diff --git a/backend/src/api/server.ts b/backend/src/api/server.ts index 4efd4429..32e3dbe1 100644 --- a/backend/src/api/server.ts +++ b/backend/src/api/server.ts @@ -11,7 +11,6 @@ import { setGlobalLogger } from './routes/logs.js'; import { ensureAuthToken, extractRequestToken, - isAuthEnabled, isPublicApiPath, allowsQueryTokenAuth, validateRequestToken, @@ -93,7 +92,12 @@ export async function initApp(): Promise { if (logConfig.log_level) logger.setLogLevel(logConfig.log_level); if (logConfig.max_log_files !== undefined) logger.setMaxLogFiles(logConfig.max_log_files); if (logConfig.log_rotation !== undefined) logger.setLogRotation(logConfig.log_rotation); - const allowedPorts = new Set([5173, 4173, 8000, 3000, 9100, 9200]); + // CORS 白名单只保留实际使用的 loopback 端口:Vite dev(5173)/preview(4173)、 + // PAPYRUS_PORT 后端自身、E2E 动态端口。 + // 原因:3000/9100/9200 无任何应用使用,却让本机任意进程绑定这些端口即可获得 + // credentials:true 的可信 origin,配合 token 泄露即完整读写 API。 + // 未全部删除 localhost origin:浏览器开发模式(Vite 5173)依赖同源策略放行。 + const allowedPorts = new Set([5173, 4173, 8000]); const configuredBackendPort = Number(process.env.PAPYRUS_PORT); const configuredE2ePortBase = Number(process.env.PAPYRUS_E2E_PORT_BASE); if (Number.isInteger(configuredBackendPort) && configuredBackendPort > 0 && configuredBackendPort <= 65535) { @@ -146,7 +150,9 @@ export async function initApp(): Promise { await registerRealtimeWebSocket(app); // Local API protection: require token on all /api routes except /api/health. - if (isAuthEnabled()) { + // 钩子无条件注册:启动中途 token 文件被删/损坏时,validateRequestToken 的 fail-closed + // 分支仍然生效;若仅在 isAuthEnabled() 时注册,同样的场景会让所有路由退化为免认证。 + { app.addHook('onRequest', async (request, reply) => { if (request.method === 'OPTIONS') { return; diff --git a/backend/src/mcp/server.ts b/backend/src/mcp/server.ts index 2675d57b..d9815e85 100644 --- a/backend/src/mcp/server.ts +++ b/backend/src/mcp/server.ts @@ -1,5 +1,5 @@ import http from 'node:http'; -import { randomBytes } from 'node:crypto'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import { executeMcpTool, getMcpToolsCatalog } from '#/mcp/tools.js'; import type { PapyrusLogger } from '../utils/logger.js'; @@ -28,7 +28,9 @@ function generateToken(): string { } function isAllowedOrigin(origin: string): boolean { - const allowedPorts = new Set([5173, 4173, 8000, 3000, 9100, 9200]); + // 与主 API(api/server.ts)保持同一收紧后的 loopback 白名单; + // 原因:3000/9100/9200 无应用使用,却让任意本机进程获得 credentials 可信 origin。 + const allowedPorts = new Set([5173, 4173, 8000]); try { const parsed = new URL(origin); const port = parsed.port ? parseInt(parsed.port, 10) : (parsed.protocol === 'https:' ? 443 : 80); @@ -112,7 +114,18 @@ export class MCPServer { } const authHeader = req.headers.authorization ?? ''; - const isAuthorized = authHeader === `Bearer ${this.authToken}`; + // timing-safe 比较 bearer token: + // 原因:明文 === 的比较时间随首个不匹配字节提前返回,本机进程可逐字节探测 token。 + // 未对长度差异做特殊分支以外的处理:timingSafeEqual 要求等长缓冲,长度先短路是必要前提。 + const isAuthorized = (() => { + const expected = `Bearer ${this.authToken}`; + const actual = Buffer.from(authHeader, 'utf8'); + const expectedBuf = Buffer.from(expected, 'utf8'); + if (actual.length !== expectedBuf.length) { + return false; + } + return timingSafeEqual(actual, expectedBuf); + })(); if (req.method === 'GET' && req.url === '/tools') { if (!isAuthorized) { diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index cb2d9ecd..dff2c038 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -21,13 +21,13 @@ function readTokenFile(): string | null { } function writeTokenFile(token: string): void { - try { - fs.mkdirSync(paths.dataDir, { recursive: true }); - fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }); - protectPrivateFile(TOKEN_FILE); - } catch (e) { - console.error(`写入认证令牌文件失败: ${e instanceof Error ? e.message : String(e)}`); - } + // 写入失败必须上抛而不是吞掉: + // 原因:吞错后 getAuthToken() 读回 null,validateRequestToken 会进入“无 token”分支, + // 数据目录只读时整个 API 将静默退化为无认证状态(fail open)。 + // 未保留 console.error 降级:日志无法阻止认证被绕过,必须让启动流程感知失败。 + fs.mkdirSync(paths.dataDir, { recursive: true }); + fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 }); + protectPrivateFile(TOKEN_FILE); } export function getOrCreateAuthToken(): string { @@ -91,8 +91,12 @@ export function allowsQueryTokenAuth(url: string): boolean { export function validateRequestToken(headerToken?: string): boolean { const expected = getAuthToken(); + // Fail closed:token 不可用时拒绝一切请求,而不是放行一切。 + // 原因:令牌文件被删除、损坏或数据目录不可读时,放行等于静默关闭认证, + // 本机任意进程都能读写全部 API(含 API Key 与笔记内容)。 + // 未沿用旧的 return true:Unknown ≠ Allowed 是本地 API 的安全底线。 if (!expected) { - return true; + return false; } if (!headerToken) { return false; @@ -108,6 +112,17 @@ export function validateRequestToken(headerToken?: string): boolean { // 服务启动时确保本地 API token 存在,避免“未配置 token = 认证关闭”。 // 原因:开发脚本常单独启动后端,必须自动生成并持久化 token。 // 未强制依赖 Electron 注入:Electron 仍可覆盖 env token,但 standalone 后端也默认受保护。 +// 失败即终止:无法建立可持久化的 token 时抛错阻止服务启动, +// 原因:继续运行只会在无认证状态下暴露 API(与规则“无凭证必须失败关闭”一致), +// 明确失败优于带着静默漏洞伪装成功。 export function ensureAuthToken(): string { - return getOrCreateAuthToken(); + try { + return getOrCreateAuthToken(); + } catch (e) { + const reason = e instanceof Error ? e.message : String(e); + throw new Error( + `无法创建或读取 API 认证 token(数据目录:${paths.dataDir}):${reason}。` + + '请检查数据目录权限,或通过环境变量 PAPYRUS_AUTH_TOKEN(至少 32 字符)显式提供 token。', + ); + } } diff --git a/backend/src/utils/proxy.ts b/backend/src/utils/proxy.ts index d3fd8f53..8d8758b6 100644 --- a/backend/src/utils/proxy.ts +++ b/backend/src/utils/proxy.ts @@ -1,5 +1,8 @@ import { execFileSync, execSync } from 'node:child_process'; -import { fetch as undiciFetch, ProxyAgent } from 'undici'; +import dns from 'node:dns'; +import type net from 'node:net'; +import { fetch as undiciFetch, Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; +import { isLoopbackAddress, isPrivateResolvedAddress, isPrivateNetworkUrl } from './security.js'; const GITHUB_DIRECT_FALLBACK_TIMEOUT_MS = 5000; @@ -151,35 +154,108 @@ function withTimeoutSignal(init: RequestInit | undefined, timeoutMs: number): Re return { ...init, signal: AbortSignal.timeout(timeoutMs) }; } -export async function fetchWithProxy(url: string, init?: RequestInit): Promise { - const proxyUrl = getProxyUrl(); - if (!proxyUrl) { - return global.fetch(url, init); - } +// SSRF 连接期校验:在 undici connect.lookup 回调里检查 DNS 解析结果。 +// 原因:字面量校验存在两类绕过——域名解析到私网 IP(DNS rebinding)、 +// 以及 fetch 默认 redirect:'follow' 被 302 跳转到内网地址。 +// lookup 发生在实际建连之前,校验通过才允许 connect,无 TOCTOU 窗口。 +// 未在请求前做一次性 dns.lookup 预检:预检与真实连接之间仍可换解析记录,防不住 rebinding。 +type SsrfLookupCallback = ( + err: NodeJS.ErrnoException | null, + addresses: dns.LookupAddress[] | dns.LookupAddress, +) => void; + +function createSsrfGuardLookup(allowLoopback: boolean): net.LookupFunction { + const guarded = ( + hostname: string, + options: dns.LookupOneOptions | dns.LookupAllOptions, + callback: SsrfLookupCallback, + ): void => { + // all: true 保证拿到全部解析地址(IPv4+IPv6),逐条校验防止只查首个记录被绕过。 + dns.lookup(hostname, { ...options, all: true }, (err, addresses) => { + if (err) { + callback(err, []); + return; + } + const blocked = addresses.find( + entry => isPrivateResolvedAddress(entry.address) + && !(allowLoopback && isLoopbackAddress(entry.address)), + ); + if (blocked) { + callback( + new Error(`SSRF 防护:${hostname} 解析到受限网络地址 ${blocked.address},已拒绝连接`), + [], + ); + return; + } + callback(null, addresses); + }); + }; + // 类型断言说明:node:net 的 LookupFunction 只描述了单地址回调形态 + // (callback(address: string, family)),但 options.all=true 时 dns.lookup 的回调 + // 实际收到 LookupAddress[]——这是 Node 类型定义无法表达的双形态,运行时由 net/tls + // 按 all 选项正确分发。此处断言仅为通过编译,行为以 dns.lookup 的 all 语义为准。 + return guarded as unknown as net.LookupFunction; +} + +// 按需创建并复用的共享 Agent:本地 Provider 场景(允许回环)。 +// 原因:Agent 内部持有连接池,复用可避免每次请求重建 TLS 会话。 +// 全局挂载而非仅在 fetchWithProxy 内传 dispatcher: +// 原因:SSRF 连接期校验必须覆盖默认 fetch 路径(global.fetch),只装在包装函数里 +// 会让进程内其他 fetch 调用点绕过校验;setGlobalDispatcher 对 global.fetch 同样生效。 +// 为何允许回环:本服务是本机桌面组件,CLI/健康检查/本地 Provider 需要合法访问回环地址; +// 私网(10/172.16/192.168/169.254)、链路本地与 ULA 仍会被拒绝。 +// 非本地 Provider 禁止回环的要求由 fetchWithProxy 的字面量预检 + 上层 validateProviderBaseUrl 保证。 +let loopbackAllowedAgent: Agent | undefined; - if (!isGitHubUrl(url)) { - return global.fetch(url, init); +function getLoopbackAllowedAgent(): Agent { + if (!loopbackAllowedAgent) { + loopbackAllowedAgent = new Agent({ connect: { lookup: createSsrfGuardLookup(true) } }); } + return loopbackAllowedAgent; +} + +setGlobalDispatcher(getLoopbackAllowedAgent()); - const proxyAgent = createProxyAgent(); - if (!proxyAgent) { - return global.fetch(url, init); +export interface SecureFetchOptions { + // 仅 keyless 本地 Provider(Ollama/LM Studio 等)允许回环地址; + // 即使为 true,私网(10/172.16/192.168/169.254)与 ULA 仍被全局 dispatcher 拒绝。 + allowLoopback?: boolean; +} + +export async function fetchWithProxy(url: string, init?: RequestInit, opts?: SecureFetchOptions): Promise { + // 非本地目标的字面量预检:本地 Provider 之外的调用禁止回环/私网字面量地址, + // 与全局 dispatcher 的解析期校验形成两层防线(字面量在发请求前就拒绝,错误信息更明确)。 + if (!opts?.allowLoopback && isPrivateNetworkUrl(url)) { + throw new Error(`SSRF 防护:禁止访问受限网络地址 ${url}`); } - try { - return await undiciFetch( - url, - { ...init, dispatcher: proxyAgent } as unknown as Parameters[1], - ); - } catch (error) { - if (!isProxyConnectionError(error)) { - throw error; - } - try { - return await global.fetch(url, withTimeoutSignal(init, GITHUB_DIRECT_FALLBACK_TIMEOUT_MS)); - } catch (directError) { - const reason = directError instanceof Error ? directError.message : String(directError); - throw new Error(`通过代理 ${proxyUrl} 连接失败,已尝试直连仍失败:${reason}`); + // 强制 redirect:'error':公网 base URL 通过校验后仍可能 302 跳内网,跟随重定向会绕过字面量校验。 + // 放在 init 展开之后覆盖,调用方无法放宽;需要重定向的场景目前不存在,失败信息由 fetch 直接抛出。 + const hardenedInit: RequestInit = { ...init, redirect: 'error' }; + + const proxyUrl = getProxyUrl(); + if (proxyUrl && isGitHubUrl(url)) { + const proxyAgent = createProxyAgent(); + if (proxyAgent) { + try { + return await undiciFetch( + url, + { ...hardenedInit, dispatcher: proxyAgent } as unknown as Parameters[1], + ) as unknown as Response; + } catch (error) { + if (!isProxyConnectionError(error)) { + throw error; + } + try { + // 直连回退经 global.fetch 走全局受保护 dispatcher,GitHub 域名不受回环预检影响。 + return await global.fetch(url, withTimeoutSignal(hardenedInit, GITHUB_DIRECT_FALLBACK_TIMEOUT_MS)); + } catch (directError) { + const reason = directError instanceof Error ? directError.message : String(directError); + throw new Error(`通过代理 ${proxyUrl} 连接失败,已尝试直连仍失败:${reason}`); + } + } } } + + return global.fetch(url, hardenedInit); } diff --git a/backend/src/utils/route-error.ts b/backend/src/utils/route-error.ts new file mode 100644 index 00000000..04f583e8 --- /dev/null +++ b/backend/src/utils/route-error.ts @@ -0,0 +1,10 @@ +// 路由 catch 块的对外错误消息消毒。 +// 原因:大量路由自行 send 500/400 响应,不经过 server.ts 的全局 setErrorHandler, +// 直接回显 err.message 会把 SQLite 错误、内部绝对路径等细节泄露给客户端。 +// 未删除服务端日志:调用方仍先 request.log.error 完整记录,消毒只影响响应体。 +// debug 模式保留原文:与全局错误处理器的 isDebugMode 门控保持同一开关,方便开发排查。 +export function routeErrorMessage(error: unknown, action: string): string { + const detail = error instanceof Error ? error.message : String(error); + const isDebug = process.env.PAPYRUS_DEBUG === '1' || process.env.NODE_ENV === 'development'; + return isDebug ? `${action}: ${detail}` : action; +} diff --git a/backend/src/utils/security.ts b/backend/src/utils/security.ts index 8d03352d..3826dad0 100644 --- a/backend/src/utils/security.ts +++ b/backend/src/utils/security.ts @@ -126,6 +126,29 @@ export function isPrivateNetworkUrl(urlStr: string): boolean { } } +// 判断 DNS 解析出的地址是否为回环地址,输入必须是已解析的规范 IP 字符串。 +// 原因:keyless 本地 Provider(Ollama/LM Studio 等)合法运行在 127.0.0.1/::1 上, +// 出站连接的 connect 期校验需要区分“允许的回环”与“禁止的其他私网地址”。 +// 未复用 isPrivateNetworkUrl:它面向 URL 字符串,这里输入是 dns.lookup 的结果。 +export function isLoopbackAddress(address: string): boolean { + const normalized = normalizeIpv6Host(address); + if (normalized.includes(':')) { + return normalized === '::1'; + } + return normalized === '127.0.0.1' || /^127\./.test(normalized); +} + +// 判断 DNS 解析出的 IP 地址是否属于私网/回环/链路本地/ULA 等受限范围,返回 true 表示应阻止连接。 +// 原因:SSRF 字面量校验拦不住“域名解析到私网 IP”的绕过,连接期必须对解析结果复查。 +// 未在 proxy.ts 内联实现:IP 分类逻辑与 isPrivateNetworkUrl 共享同一套 IPv4/IPv6 规则,集中维护防漏。 +export function isPrivateResolvedAddress(address: string): boolean { + const normalized = normalizeIpv6Host(address); + if (net.isIP(normalized) === 6) { + return isPrivateIpv6(normalized); + } + return isPrivateIpv4(normalized); +} + // 返回可信域名上的 http/https URL,不可信或非法 URL 返回 null。 // 原因:更新检查会把远端 URL 交给前端展示,必须避免 file/javascript/恶意域名。 // 未只校验协议:HTTPS 恶意域名仍可能诱导下载安装非官方文件。 diff --git a/backend/tests/unit/auth.test.ts b/backend/tests/unit/auth.test.ts index a28c6693..34df6f3f 100644 --- a/backend/tests/unit/auth.test.ts +++ b/backend/tests/unit/auth.test.ts @@ -75,11 +75,13 @@ describe('auth', () => { expect(getOrCreateAuthToken()).toBe(first); }); - it('should return null when auth disabled', () => { + it('should fail closed when no token can be established', () => { + // Fail closed:token 文件缺失/不可读时必须拒绝一切请求。 + // 原因:旧行为在此场景返回 true(放行全部),数据目录只读时整个 API 会静默裸奔。 expect(getAuthToken()).toBeNull(); expect(isAuthEnabled()).toBe(false); - expect(validateRequestToken()).toBe(true); - expect(validateRequestToken('anything')).toBe(true); + expect(validateRequestToken()).toBe(false); + expect(validateRequestToken('anything')).toBe(false); }); it('should reject short env token', () => { @@ -117,6 +119,25 @@ describe('auth', () => { expect(getAuthToken()).toBe(token); }); + // 模拟“token 无法持久化”用目录占用 token 路径的方式: + // 原因:chmod 只读在特权用户(CI 容器内的 root 等)下不阻止写入,测试会不稳定地通过; + // writeFileSync 写入目录路径抛 EISDIR 在任何用户与平台都确定成立。 + it('should abort startup when the token file cannot be persisted', () => { + delete process.env.PAPYRUS_AUTH_TOKEN; + const tokenFile = path.join(testDir, '.api_token'); + fs.rmSync(tokenFile, { force: true, recursive: true }); + fs.mkdirSync(tokenFile); + try { + // token 无法持久化时启动助手必须抛错终止, + // 而不是带着“无 token”状态继续运行(那等于无认证暴露 API)。 + expect(() => ensureAuthToken()).toThrow(/无法创建或读取 API 认证 token/); + // 抛错属于致命失败,不得留下半初始化状态供后续请求误用。 + expect(validateRequestToken('anything')).toBe(false); + } finally { + fs.rmSync(tokenFile, { recursive: true, force: true }); + } + }); + describe('edge cases', () => { it('should reject token with different length', () => { process.env.PAPYRUS_AUTH_TOKEN = 'a'.repeat(32); diff --git a/backend/tests/unit/proxy.test.ts b/backend/tests/unit/proxy.test.ts index 128fc0c2..50558dbe 100644 --- a/backend/tests/unit/proxy.test.ts +++ b/backend/tests/unit/proxy.test.ts @@ -1,10 +1,32 @@ -import { +// Mock undici 的 fetch 以拦截 GitHub 代理路径的实际出站调用,避免测试产生真实网络请求。 +// ProxyAgent 保留真实实现,用于断言代理路径选择的 dispatcher 类型。 +// 原因:本仓库 jest 运行在原生 ESM 模式(--experimental-vm-modules), +// CJS 风格的 jest.mock 工厂不可用,必须用 unstable_mockModule + 动态导入。 +// 非代理路径与 SSRF 连接期校验走 global.fetch(受全局受保护 dispatcher 影响),单独以 +// global.fetch 替换 / dns.lookup spy 覆盖。 +import { jest } from '@jest/globals'; +import dns from 'node:dns'; + +jest.unstable_mockModule('undici', async () => { + // requireActual 是唯一不会被 mock 拦截的加载路径; + // 工厂内直接 import('undici') 会递归触发本 mock,导致无限循环。 + const actual = jest.requireActual('undici') as Record; + return { ...actual, fetch: jest.fn() }; +}); + +const { fetchWithProxy, getProxyUrl, createProxyAgent, isProxyConnectionError, parseMacProxyConfiguration, -} from '../../src/utils/proxy.js'; +} = await import('../../src/utils/proxy.js'); +const undici = await import('undici'); + +// undici.fetch 的声明类型是真实 fetch,测试里按 jest.Mock 使用需要收窄。 +// 原因:mock 工厂替换了运行时实现,类型系统无法感知。 +const fetchMock = undici.fetch as unknown as jest.Mock; +const UndiciProxyAgent = undici.ProxyAgent; describe('proxy utilities', () => { const originalFetch = global.fetch; @@ -16,6 +38,7 @@ describe('proxy utilities', () => { delete process.env.HTTPS_PROXY; delete process.env.http_proxy; delete process.env.https_proxy; + fetchMock.mockReset(); }); afterEach(() => { @@ -92,12 +115,12 @@ describe('proxy utilities', () => { }); describe('fetchWithProxy', () => { - it('should use global.fetch when no proxy is configured', async () => { + it('should use global.fetch with redirect error when no proxy is configured', async () => { const mockResponse = { ok: true, status: 200 } as Response; let calledUrl: string | undefined; let calledInit: RequestInit | undefined; - global.fetch = (url: string, init?: RequestInit) => { - calledUrl = url; + global.fetch = (url: string | URL, init?: RequestInit) => { + calledUrl = String(url); calledInit = init; return Promise.resolve(mockResponse); }; @@ -105,46 +128,115 @@ describe('proxy utilities', () => { const result = await fetchWithProxy('https://example.com/test'); expect(calledUrl).toBe('https://example.com/test'); - expect(calledInit).toBeUndefined(); + expect(calledInit?.redirect).toBe('error'); + expect(fetchMock).not.toHaveBeenCalled(); expect(result).toBe(mockResponse); }); - it('should pass init options through when no proxy is configured', async () => { + it('should pass init options through while forcing redirect error', async () => { const mockResponse = { ok: true, status: 200 } as Response; - let calledUrl: string | undefined; let calledInit: RequestInit | undefined; - global.fetch = (url: string, init?: RequestInit) => { - calledUrl = url; + global.fetch = (_url: string | URL, init?: RequestInit) => { calledInit = init; return Promise.resolve(mockResponse); }; - const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } } as RequestInit; + const init = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + } as RequestInit; await fetchWithProxy('https://example.com/test', init); - expect(calledUrl).toBe('https://example.com/test'); - expect(calledInit).toBe(init); + expect(calledInit?.method).toBe('POST'); + expect(calledInit?.headers).toEqual({ 'Content-Type': 'application/json' }); + // redirect 必须被覆盖为 error:调用方不能放宽 SSRF 重定向防护。 + expect(calledInit?.redirect).toBe('error'); + }); + + it('should reject non-loopback literal URLs without allowLoopback before any request', async () => { + let fetchCalled = false; + global.fetch = () => { + fetchCalled = true; + return Promise.resolve({ ok: true } as Response); + }; + + await expect(fetchWithProxy('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/受限网络地址/); + expect(fetchCalled).toBe(false); + }); + + it('should bypass proxy for AI API URLs (not GitHub)', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + let globalFetchUsed = false; + global.fetch = () => { + globalFetchUsed = true; + return Promise.resolve(mockResponse); + }; + + const result = await fetchWithProxy('https://api.openai.com/v1/chat/completions'); + + expect(globalFetchUsed).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toBe(mockResponse); }); - it('should not crash when proxy is configured', async () => { + it('should bypass proxy for local AI services when allowLoopback is set', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - // When proxy is configured, fetchWithProxy uses undiciFetch which will fail - // because there's no actual proxy server. We just verify it attempts the call - // and throws a network-level error rather than a type/programming error. - await expect(fetchWithProxy('http://localhost:99999/test')).rejects.toThrow(); + const mockResponse = { ok: true, status: 200 } as Response; + global.fetch = () => Promise.resolve(mockResponse); + + const result = await fetchWithProxy('http://localhost:11434/api/tags', undefined, { allowLoopback: true }); + + expect(result).toBe(mockResponse); + }); + + it('should attempt to use proxy for GitHub API (update check)', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + fetchMock.mockResolvedValue(mockResponse); + + const result = await fetchWithProxy('https://api.github.com/repos/PapyrusOR/Papyrus_Desktop/releases/latest'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]?.[1] as Record; + expect(init.dispatcher).toBeInstanceOf(UndiciProxyAgent); + expect(init.redirect).toBe('error'); + expect(result).toBe(mockResponse); + }); + + it('should attempt to use proxy for raw.githubusercontent.com', async () => { + process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + const mockResponse = { ok: true, status: 200 } as Response; + fetchMock.mockResolvedValue(mockResponse); + + const result = await fetchWithProxy('https://raw.githubusercontent.com/PapyrusOR/Papyrus_Desktop/main/README.md'); + + const init = fetchMock.mock.calls[0]?.[1] as Record; + expect(init.dispatcher).toBeInstanceOf(UndiciProxyAgent); + expect(result).toBe(mockResponse); }); it('should fall back to global.fetch when proxy server is unreachable', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; const mockResponse = { ok: true, status: 200 } as Response; + const proxyError = new TypeError('fetch failed'); + // 构造与 undici 运行时一致的 mock error shape + (proxyError as unknown as { cause: { code: string } }).cause = { code: 'ECONNREFUSED' }; + fetchMock.mockRejectedValueOnce(proxyError); global.fetch = () => Promise.resolve(mockResponse); const result = await fetchWithProxy('https://api.github.com/test'); + + expect(fetchMock).toHaveBeenCalledTimes(1); expect(result).toBe(mockResponse); }); it('should throw wrapped error when both proxy and direct fail', async () => { process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + const proxyError = new TypeError('fetch failed'); + (proxyError as unknown as { cause: { code: string } }).cause = { code: 'ECONNREFUSED' }; + fetchMock.mockRejectedValueOnce(proxyError); global.fetch = () => Promise.reject(new Error('Direct fetch failed')); await expect(fetchWithProxy('https://api.github.com/test')).rejects.toThrow( @@ -161,65 +253,88 @@ describe('proxy utilities', () => { connError.cause = { code: 'ECONNREFUSED' }; expect(isProxyConnectionError(connError)).toBe(true); }); + }); - it('should bypass proxy for AI API URLs (not GitHub)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + describe('fetchWithProxy SSRF connect-time guard', () => { + // 同 realm 的真实 undici fetch:jest 的 vm realm 分离会让内置 global.fetch 读不到 + // npm undici setGlobalDispatcher 挂载的受保护 dispatcher(生产环境单 realm 不受影响, + // 已在纯 Node 下验证)。测试中显式替换为同 realm 的 undici fetch 以走完整 guard 链路。 + const actualUndiciFetch = (jest.requireActual('undici') as { fetch: typeof fetch }).fetch; + let dnsLookupMock: jest.Mock; + + beforeEach(() => { + global.fetch = actualUndiciFetch; + // dns.lookup 的多重重载与 jest.Mock 泛型不兼容,测试里以受控 spy 伪造解析结果。 + dnsLookupMock = jest.spyOn(dns, 'lookup') as unknown as jest.Mock; + }); - const result = await fetchWithProxy('https://api.openai.com/v1/chat/completions'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + afterEach(() => { + dnsLookupMock.mockRestore(); }); - it('should bypass proxy for local AI services (Ollama, etc.)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + // undici 把连接层错误包成 TypeError('fetch failed'),真实原因在 cause 链上, + // 断言必须遍历整条链才能区分 SSRF 拦截与其他网络错误。 + // 不用 instanceof Error:'fetch failed' 由 Node 内部 fetch 抛出,跨 jest vm realm + // 的 instanceof 判定会失败,改为按 message 属性做鸭子类型判断。 + function deepErrorMessage(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + while ( + current !== null && typeof current === 'object' && + 'message' in current && typeof (current as { message?: unknown }).message === 'string' + ) { + parts.push((current as { message: string }).message); + current = (current as { cause?: unknown }).cause; + } + return parts.join('\n'); + } - const result = await fetchWithProxy('http://localhost:11434/api/tags'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + it('should reject connections whose hostname resolves to a private address', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '169.254.169.254', family: 4 }]); + }, + ); + + const error = await fetchWithProxy('http://metadata.attacker.example/').catch((e: unknown) => e); + expect(deepErrorMessage(error)).toMatch(/受限网络地址/); }); - it('should bypass proxy for other external APIs (not GitHub)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; - const mockResponse = { ok: true, status: 200 } as Response; - let calledWithGlobalFetch = false; - global.fetch = () => { - calledWithGlobalFetch = true; - return Promise.resolve(mockResponse); - }; + it('should reject loopback resolutions without allowLoopback even for keyless-style URLs', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '127.0.0.1', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://api.deepseek.com/v1/chat/completions'); - expect(calledWithGlobalFetch).toBe(true); - expect(result).toBe(mockResponse); + // 字面量 localhost 在预检阶段即被拒绝(未带 allowLoopback),不会发起任何请求。 + const error = await fetchWithProxy('http://localhost:11434/api/tags').catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/受限网络地址/); }); - it('should attempt to use proxy for GitHub API (update check)', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; - const mockResponse = { ok: true, status: 200 } as Response; - global.fetch = () => Promise.resolve(mockResponse); + it('should allow loopback resolutions when allowLoopback is set', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '127.0.0.1', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://api.github.com/repos/PapyrusOR/Papyrus_Desktop/releases/latest'); - expect(result).toBe(mockResponse); + // 端口 1 上没有服务,连接会被拒绝——但错误必须是连接层错误而非 SSRF 拦截, + // 证明回环地址已通过全局 dispatcher 的 guard 进入真实建连阶段。 + const error = await fetchWithProxy('http://127.0.0.1:1/api/tags', undefined, { allowLoopback: true }).catch((e: unknown) => e); + expect(deepErrorMessage(error)).not.toMatch(/受限网络地址/); }); - it('should attempt to use proxy for raw.githubusercontent.com', async () => { - process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; - const mockResponse = { ok: true, status: 200 } as Response; - global.fetch = () => Promise.resolve(mockResponse); + it('should still block private non-loopback resolutions when allowLoopback is set', async () => { + dnsLookupMock.mockImplementation( + (_hostname: string, _options: unknown, callback: (err: unknown, addresses: unknown) => void) => { + callback(null, [{ address: '192.168.1.10', family: 4 }]); + }, + ); - const result = await fetchWithProxy('https://raw.githubusercontent.com/PapyrusOR/Papyrus_Desktop/main/README.md'); - expect(result).toBe(mockResponse); + const error = await fetchWithProxy('http://lan-service.example/api/tags', undefined, { allowLoopback: true }).catch((e: unknown) => e); + expect(deepErrorMessage(error)).toMatch(/受限网络地址/); }); }); }); diff --git a/backend/tests/unit/security.test.ts b/backend/tests/unit/security.test.ts index 3163cba1..1e7dc8a9 100644 --- a/backend/tests/unit/security.test.ts +++ b/backend/tests/unit/security.test.ts @@ -2,7 +2,13 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from '@jest/globals'; -import { isPathInsideDirectory, resolveRealPathForSecurity } from '../../src/utils/security.js'; +import { + isLoopbackAddress, + isPathInsideDirectory, + isPrivateResolvedAddress, + isPrivateNetworkUrl, + resolveRealPathForSecurity, +} from '../../src/utils/security.js'; describe('security path helpers', () => { const tempDirs: string[] = []; @@ -46,3 +52,55 @@ describe('security path helpers', () => { expect(isPathInsideDirectory(escapedTarget, allowedRoot)).toBe(false); }); }); + +describe('SSRF resolved-address helpers', () => { + it('classifies loopback addresses for IPv4 and IPv6', () => { + expect(isLoopbackAddress('127.0.0.1')).toBe(true); + expect(isLoopbackAddress('127.8.8.8')).toBe(true); + expect(isLoopbackAddress('::1')).toBe(true); + expect(isLoopbackAddress('[::1]')).toBe(true); + + expect(isLoopbackAddress('192.168.1.1')).toBe(false); + expect(isLoopbackAddress('8.8.8.8')).toBe(false); + expect(isLoopbackAddress('2606:4700::1')).toBe(false); + }); + + it('blocks private, link-local and ULA resolved addresses', () => { + for (const address of [ + '10.0.0.5', + '172.16.0.1', + '172.31.255.255', + '192.168.0.10', + '169.254.169.254', + '0.0.0.0', + '127.0.0.1', + 'fe80::1', + 'fd00::1', + 'fc12:3456::1', + '::1', + '::ffff:10.0.0.1', + '::ffff:169.254.169.254', + ]) { + expect(isPrivateResolvedAddress(address)).toBe(true); + } + }); + + it('allows public resolved addresses', () => { + for (const address of ['8.8.8.8', '1.1.1.1', '172.32.0.1', '2606:4700:4700::1111', '2001:db8::1']) { + expect(isPrivateResolvedAddress(address)).toBe(false); + } + }); + + it('keeps URL literal detection consistent with resolved-address rules', () => { + // 同一地址在 URL 字面量与 DNS 解析结果两条路径上必须同判,防止规则漂移。 + for (const url of [ + 'http://127.0.0.1:8000/', + 'http://10.0.0.5/', + 'http://169.254.169.254/latest/meta-data/', + 'http://[fd00::1]/', + ]) { + expect(isPrivateNetworkUrl(url)).toBe(true); + } + expect(isPrivateNetworkUrl('http://8.8.8.8/')).toBe(false); + }); +}); diff --git a/electron/diagnostic-preload.js b/electron/diagnostic-preload.js index 5974313e..200063f6 100644 --- a/electron/diagnostic-preload.js +++ b/electron/diagnostic-preload.js @@ -11,7 +11,13 @@ function isAllowedPath(dir) { path.join(app.getPath('home'), '.papyrus'), ]; const resolved = path.resolve(dir); - return allowed.some(a => resolved.startsWith(path.resolve(a))); + // 使用 path.relative 包含性判断而非 startsWith: + // 原因:startsWith 会把 "PapyrusData-backup" 这类同前缀兄弟目录误判为白名单内。 + // 与 security-validators.js 保持同一实现,避免两套边界判断语义漂移。 + return allowed.some(a => { + const relative = path.relative(path.resolve(a), resolved); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }); } contextBridge.exposeInMainWorld('diagnosticAPI', { diff --git a/electron/main.js b/electron/main.js index a39677ef..2c4bfa56 100644 --- a/electron/main.js +++ b/electron/main.js @@ -844,6 +844,34 @@ app.on('web-contents-created', (event, contents) => { shell.openExternal(validation.url); } }); + + // SECURITY: 主窗口导航白名单。 + // 原因:setWindowOpenHandler 只拦截 window.open,renderer 仍可通过 location 赋值 + // 把整个窗口导航到任意站点;一旦成功,本地 file:// 源即被替换。 + // 未放行 http(s) 外站:外部链接统一由 new-window/openExternal 走系统浏览器。 + // dev 放行 Vite 开发服务器(含 HMR 触发的整页重载),生产仅放行 file:// 应用自身。 + contents.on('will-navigate', (event, navigationUrl) => { + let allowed = false; + try { + const parsed = new URL(navigationUrl); + if (parsed.protocol === 'file:') { + allowed = true; + } else if ( + isDevMode && + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') && + parsed.port === new URL(CONFIG.frontendDevUrl).port + ) { + allowed = true; + } + } catch { + allowed = false; + } + if (!allowed) { + log(`[SECURITY] Blocked navigation to ${navigationUrl}`, 'warning'); + event.preventDefault(); + } + }); }); // Handle certificate errors in development diff --git a/frontend/index.html b/frontend/index.html index 4e248afd..4eb32615 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,10 @@ - + + Papyrus Desktop