diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 1cb016ed4..fbc72adbb 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -4,4 +4,4 @@ Use GitHub's [private vulnerability reporting](https://github.com/bookorbit/bookorbit/security/advisories/new) to report security issues confidentially. Do not open public issues for security vulnerabilities. -Reports are typically acknowledged within a few days, and fixes are published as quickly as severity warrants. +We aim to acknowledge vulnerability reports within 3 business days and provide a status update within 14 days. Please allow up to 90 days for investigation, remediation, and coordinated disclosure before publishing details. Critical vulnerabilities may be disclosed and fixed sooner when users face active risk. diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..6833e7907 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,51 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + push: + branches: [main] + schedule: + - cron: "30 1 * * 6" + workflow_dispatch: + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.event.repository.default_branch == github.ref_name || github.event_name == 'branch_protection_rule' + permissions: + actions: read # Read workflow metadata used by Scorecard checks. + contents: read # Read the repository being analyzed. + id-token: write # Publish signed Scorecard results. + security-events: write # Upload the SARIF report to code scanning. + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + sarif_file: results.sarif diff --git a/README.md b/README.md index 12dec611a..3c0f2aa9e 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ BookOrbit is available in English, German, Dutch, Brazilian Portuguese, and Slov - **Questions and discussion:** [GitHub Discussions](https://github.com/bookorbit/bookorbit/discussions) - **Bug reports:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=bug_report.yml) - **Feature requests:** [GitHub Issues](https://github.com/bookorbit/bookorbit/issues/new?template=feature_request.yml) +- **Security vulnerabilities:** Follow the private reporting process in the [Security Policy](.github/SECURITY.md). --- diff --git a/client/src/features/book/lib/description-html.test.ts b/client/src/features/book/lib/description-html.test.ts index f0858cb96..32a558d9c 100644 --- a/client/src/features/book/lib/description-html.test.ts +++ b/client/src/features/book/lib/description-html.test.ts @@ -18,6 +18,12 @@ describe('description-html', () => { expect(html).toBe('

Hello

') }) + it('does not treat nested script-like input as visible markup', () => { + const html = sanitizeDescriptionHtml('ipt>alert(1)ipt>') + + expect(html).not.toContain(' { expect(normalizeDescriptionHtml('


')).toBeNull() expect(normalizeDescriptionHtml('')).toBeNull() diff --git a/client/src/features/book/lib/description-html.ts b/client/src/features/book/lib/description-html.ts index c07721f49..48770f367 100644 --- a/client/src/features/book/lib/description-html.ts +++ b/client/src/features/book/lib/description-html.ts @@ -16,11 +16,7 @@ export function normalizeDescriptionHtml(content: string | null | undefined): st } function hasVisibleDescriptionText(content: string): boolean { - const text = content - .replace(//gi, '') - .replace(/<[^>]*>/g, '') - .replace(/ /gi, ' ') - .trim() - - return text.length > 0 + const template = document.createElement('template') + template.innerHTML = content + return (template.content.textContent ?? '').trim().length > 0 } diff --git a/client/src/features/whats-new/lib/markdown.test.ts b/client/src/features/whats-new/lib/markdown.test.ts index 5f2735979..143e4f958 100644 --- a/client/src/features/whats-new/lib/markdown.test.ts +++ b/client/src/features/whats-new/lib/markdown.test.ts @@ -27,6 +27,17 @@ describe('renderChangelogMarkdown', () => { expect(html).not.toContain('javascript:') }) + it('escapes quotes in markdown link destinations before creating attributes', () => { + const html = renderChangelogMarkdown('[safe](https://example.com/"onmouseover="alert(1))') + const template = document.createElement('template') + template.innerHTML = html + const anchor = template.content.querySelector('a') + + expect(anchor).not.toBeNull() + expect(anchor!.getAttribute('onmouseover')).toBeNull() + expect(anchor!.getAttributeNames().sort()).toEqual(['href', 'rel', 'target']) + }) + it('returns empty string for empty input', () => { expect(renderChangelogMarkdown(null)).toBe('') expect(renderChangelogMarkdown('')).toBe('') diff --git a/client/src/features/whats-new/lib/markdown.ts b/client/src/features/whats-new/lib/markdown.ts index 47212e632..171f558f7 100644 --- a/client/src/features/whats-new/lib/markdown.ts +++ b/client/src/features/whats-new/lib/markdown.ts @@ -36,7 +36,7 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => { }) function escapeHtml(text: string): string { - return text.replace(/&/g, '&').replace(//g, '>') + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') } function renderInline(text: string): string { diff --git a/package.json b/package.json index e8c6b272d..7d630da0d 100644 --- a/package.json +++ b/package.json @@ -82,10 +82,15 @@ }, "pnpm": { "overrides": { + "@xhmikosr/decompress": "^11.1.3", "@esbuild-kit/core-utils>esbuild": "^0.25.12", + "esbuild": "^0.28.1", "fastify": "^5.8.5", "fast-uri": "^3.1.1", "fast-xml-builder": "^1.1.7", + "form-data": "^4.0.6", + "piscina": "^4.9.3", + "qs": "^6.15.2", "ws": "^8.21.0" }, "packageExtensions": { diff --git a/packages/types/src/pattern-resolver.ts b/packages/types/src/pattern-resolver.ts index a066ad265..86a7f8edc 100644 --- a/packages/types/src/pattern-resolver.ts +++ b/packages/types/src/pattern-resolver.ts @@ -142,10 +142,7 @@ function normalizeResolverOptions(options?: PathResolverOptions): Required 0 && (value[end - 1] === "." || value[end - 1] === " ")) end -= 1; + return end === value.length ? value : value.slice(0, end); +} + function sanitizeResolutionValues(values: Record, options: Required): Record { if (!options.sanitizeForCrossPlatform) return values; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ed241750..c32021f22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,15 @@ settings: excludeLinksFromLockfile: false overrides: + "@xhmikosr/decompress": ^11.1.3 "@esbuild-kit/core-utils>esbuild": ^0.25.12 + esbuild: ^0.28.1 fastify: ^5.8.5 fast-uri: ^3.1.1 fast-xml-builder: ^1.1.7 + form-data: ^4.0.6 + piscina: ^4.9.3 + qs: ^6.15.2 ws: ^8.21.0 packageExtensionsChecksum: sha256-ULrwTwUMEKFfvEibJQ6qES0qFwzNudDsk+kRqXTIxtc= @@ -137,7 +142,7 @@ importers: version: 1.24.0(vue@3.5.39(typescript@6.0.3)) "@tailwindcss/vite": specifier: ^4.3.2 - version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) "@tanstack/vue-table": specifier: ^8.21.3 version: 8.21.3(vue@3.5.39(typescript@6.0.3)) @@ -203,7 +208,7 @@ importers: version: 4.3.2 vite-plugin-pwa: specifier: ^1.3.0 - version: 1.3.0(@vite-pwa/assets-generator@1.0.2(@types/node@26.1.1))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 1.3.0(@vite-pwa/assets-generator@1.0.2(@types/node@26.1.1))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1) vue: specifier: ^3.5.39 version: 3.5.39(typescript@6.0.3) @@ -218,7 +223,7 @@ importers: version: 11.4.6(vue@3.5.39(typescript@6.0.3)) vue-router: specifier: ^5.1.0 - version: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + version: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) vue-sonner: specifier: ^2.0.9 version: 2.0.9 @@ -228,7 +233,7 @@ importers: devDependencies: "@intlify/unplugin-vue-i18n": specifier: ^11.2.4 - version: 11.2.4(@vue/compiler-dom@3.5.39)(eslint@10.6.0(jiti@2.7.0))(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + version: 11.2.4(@vue/compiler-dom@3.5.39)(eslint@10.6.0(jiti@2.7.0))(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) "@tsconfig/node24": specifier: ^24.0.4 version: 24.0.4 @@ -246,7 +251,7 @@ importers: version: 1.0.2(@types/node@26.1.1) "@vitejs/plugin-vue": specifier: ^6.0.7 - version: 6.0.7(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + version: 6.0.7(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) "@vitest/coverage-v8": specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -297,13 +302,13 @@ importers: version: 6.0.3 vite: specifier: ^8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vite-plugin-vue-devtools: specifier: ^8.1.5 - version: 8.1.5(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + version: 8.1.5(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) vue-tsc: specifier: ^3.3.7 version: 3.3.7(typescript@6.0.3) @@ -471,8 +476,8 @@ importers: version: 4.4.3 devDependencies: "@eslint/eslintrc": - specifier: ^3.3.5 - version: 3.3.5 + specifier: ^3.3.6 + version: 3.3.6 "@eslint/js": specifier: ^10.0.1 version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) @@ -484,7 +489,7 @@ importers: version: 2.1.2 "@nestjs/cli": specifier: ^11.0.23 - version: 11.0.23(@swc/cli@0.8.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(chokidar@4.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(esbuild@0.28.0)(prettier@3.9.5) + version: 11.0.23(@swc/cli@0.8.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(chokidar@4.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(esbuild@0.28.1)(prettier@3.9.5) "@nestjs/schematics": specifier: ^11.1.0 version: 11.1.0(chokidar@4.0.3)(prettier@3.9.5)(typescript@6.0.3) @@ -556,7 +561,7 @@ importers: version: 7.2.2 ts-loader: specifier: ^9.6.2 - version: 9.6.2(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)) + version: 9.6.2(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(typescript@6.0.3) @@ -574,7 +579,7 @@ importers: version: 8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) packages: 7z-wasm@1.2.0: @@ -2194,10 +2199,10 @@ packages: cpu: [ppc64] os: [aix] - "@esbuild/aix-ppc64@0.28.0": + "@esbuild/aix-ppc64@0.28.1": resolution: { - integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==, + integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==, } engines: { node: ">=18" } cpu: [ppc64] @@ -2212,10 +2217,10 @@ packages: cpu: [arm64] os: [android] - "@esbuild/android-arm64@0.28.0": + "@esbuild/android-arm64@0.28.1": resolution: { - integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==, + integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==, } engines: { node: ">=18" } cpu: [arm64] @@ -2230,10 +2235,10 @@ packages: cpu: [arm] os: [android] - "@esbuild/android-arm@0.28.0": + "@esbuild/android-arm@0.28.1": resolution: { - integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==, + integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==, } engines: { node: ">=18" } cpu: [arm] @@ -2248,10 +2253,10 @@ packages: cpu: [x64] os: [android] - "@esbuild/android-x64@0.28.0": + "@esbuild/android-x64@0.28.1": resolution: { - integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==, + integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==, } engines: { node: ">=18" } cpu: [x64] @@ -2266,10 +2271,10 @@ packages: cpu: [arm64] os: [darwin] - "@esbuild/darwin-arm64@0.28.0": + "@esbuild/darwin-arm64@0.28.1": resolution: { - integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==, + integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==, } engines: { node: ">=18" } cpu: [arm64] @@ -2284,10 +2289,10 @@ packages: cpu: [x64] os: [darwin] - "@esbuild/darwin-x64@0.28.0": + "@esbuild/darwin-x64@0.28.1": resolution: { - integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==, + integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==, } engines: { node: ">=18" } cpu: [x64] @@ -2302,10 +2307,10 @@ packages: cpu: [arm64] os: [freebsd] - "@esbuild/freebsd-arm64@0.28.0": + "@esbuild/freebsd-arm64@0.28.1": resolution: { - integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==, + integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==, } engines: { node: ">=18" } cpu: [arm64] @@ -2320,10 +2325,10 @@ packages: cpu: [x64] os: [freebsd] - "@esbuild/freebsd-x64@0.28.0": + "@esbuild/freebsd-x64@0.28.1": resolution: { - integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==, + integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==, } engines: { node: ">=18" } cpu: [x64] @@ -2338,10 +2343,10 @@ packages: cpu: [arm64] os: [linux] - "@esbuild/linux-arm64@0.28.0": + "@esbuild/linux-arm64@0.28.1": resolution: { - integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==, + integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==, } engines: { node: ">=18" } cpu: [arm64] @@ -2356,10 +2361,10 @@ packages: cpu: [arm] os: [linux] - "@esbuild/linux-arm@0.28.0": + "@esbuild/linux-arm@0.28.1": resolution: { - integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==, + integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==, } engines: { node: ">=18" } cpu: [arm] @@ -2374,10 +2379,10 @@ packages: cpu: [ia32] os: [linux] - "@esbuild/linux-ia32@0.28.0": + "@esbuild/linux-ia32@0.28.1": resolution: { - integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==, + integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==, } engines: { node: ">=18" } cpu: [ia32] @@ -2392,10 +2397,10 @@ packages: cpu: [loong64] os: [linux] - "@esbuild/linux-loong64@0.28.0": + "@esbuild/linux-loong64@0.28.1": resolution: { - integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==, + integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==, } engines: { node: ">=18" } cpu: [loong64] @@ -2410,10 +2415,10 @@ packages: cpu: [mips64el] os: [linux] - "@esbuild/linux-mips64el@0.28.0": + "@esbuild/linux-mips64el@0.28.1": resolution: { - integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==, + integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==, } engines: { node: ">=18" } cpu: [mips64el] @@ -2428,10 +2433,10 @@ packages: cpu: [ppc64] os: [linux] - "@esbuild/linux-ppc64@0.28.0": + "@esbuild/linux-ppc64@0.28.1": resolution: { - integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==, + integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==, } engines: { node: ">=18" } cpu: [ppc64] @@ -2446,10 +2451,10 @@ packages: cpu: [riscv64] os: [linux] - "@esbuild/linux-riscv64@0.28.0": + "@esbuild/linux-riscv64@0.28.1": resolution: { - integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==, + integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==, } engines: { node: ">=18" } cpu: [riscv64] @@ -2464,10 +2469,10 @@ packages: cpu: [s390x] os: [linux] - "@esbuild/linux-s390x@0.28.0": + "@esbuild/linux-s390x@0.28.1": resolution: { - integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==, + integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==, } engines: { node: ">=18" } cpu: [s390x] @@ -2482,10 +2487,10 @@ packages: cpu: [x64] os: [linux] - "@esbuild/linux-x64@0.28.0": + "@esbuild/linux-x64@0.28.1": resolution: { - integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==, + integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==, } engines: { node: ">=18" } cpu: [x64] @@ -2500,10 +2505,10 @@ packages: cpu: [arm64] os: [netbsd] - "@esbuild/netbsd-arm64@0.28.0": + "@esbuild/netbsd-arm64@0.28.1": resolution: { - integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==, + integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==, } engines: { node: ">=18" } cpu: [arm64] @@ -2518,10 +2523,10 @@ packages: cpu: [x64] os: [netbsd] - "@esbuild/netbsd-x64@0.28.0": + "@esbuild/netbsd-x64@0.28.1": resolution: { - integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==, + integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==, } engines: { node: ">=18" } cpu: [x64] @@ -2536,10 +2541,10 @@ packages: cpu: [arm64] os: [openbsd] - "@esbuild/openbsd-arm64@0.28.0": + "@esbuild/openbsd-arm64@0.28.1": resolution: { - integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==, + integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==, } engines: { node: ">=18" } cpu: [arm64] @@ -2554,10 +2559,10 @@ packages: cpu: [x64] os: [openbsd] - "@esbuild/openbsd-x64@0.28.0": + "@esbuild/openbsd-x64@0.28.1": resolution: { - integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==, + integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==, } engines: { node: ">=18" } cpu: [x64] @@ -2572,10 +2577,10 @@ packages: cpu: [arm64] os: [openharmony] - "@esbuild/openharmony-arm64@0.28.0": + "@esbuild/openharmony-arm64@0.28.1": resolution: { - integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==, + integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==, } engines: { node: ">=18" } cpu: [arm64] @@ -2590,10 +2595,10 @@ packages: cpu: [x64] os: [sunos] - "@esbuild/sunos-x64@0.28.0": + "@esbuild/sunos-x64@0.28.1": resolution: { - integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==, + integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==, } engines: { node: ">=18" } cpu: [x64] @@ -2608,10 +2613,10 @@ packages: cpu: [arm64] os: [win32] - "@esbuild/win32-arm64@0.28.0": + "@esbuild/win32-arm64@0.28.1": resolution: { - integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==, + integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==, } engines: { node: ">=18" } cpu: [arm64] @@ -2626,10 +2631,10 @@ packages: cpu: [ia32] os: [win32] - "@esbuild/win32-ia32@0.28.0": + "@esbuild/win32-ia32@0.28.1": resolution: { - integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==, + integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==, } engines: { node: ">=18" } cpu: [ia32] @@ -2644,10 +2649,10 @@ packages: cpu: [x64] os: [win32] - "@esbuild/win32-x64@0.28.0": + "@esbuild/win32-x64@0.28.1": resolution: { - integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==, + integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==, } engines: { node: ">=18" } cpu: [x64] @@ -2690,10 +2695,10 @@ packages: } engines: { node: ^20.19.0 || ^22.13.0 || >=24 } - "@eslint/eslintrc@3.3.5": + "@eslint/eslintrc@3.3.6": resolution: { - integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==, + integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==, } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } @@ -6716,17 +6721,17 @@ packages: } engines: { node: ">=20" } - "@xhmikosr/decompress-unzip@8.1.0": + "@xhmikosr/decompress-unzip@8.2.1": resolution: { - integrity: sha512-hVcpEZIS8avXU1ioR0Pb2LcBYHfah1lzzTQPDItkBi3W+kSE/DxSeEgOoHJB8rn+Izm0ArWZxxlpsvEK4ySjaw==, + integrity: sha512-2MS94QnmXQwjkKN8WyFiu1sU7J3rcWJcMze4kRYsX7tN+CXpUGECgkh4YSOhujpkWPuVlFudIziJHO/TxOqkQQ==, } engines: { node: ">=20" } - "@xhmikosr/decompress@11.1.1": + "@xhmikosr/decompress@11.1.3": resolution: { - integrity: sha512-KdjwFbTzcpGaTIPncNaPLOHocBSF1hHo4s7gr+ZzzoB2bzVzFumzawqKTij0Vpw0idM4C2FZFPktIfyznkeJTQ==, + integrity: sha512-NiyhJq6z7ERsYghcnXZUI6ooDXgZtoB+G9eUsYhfSM4VLp2rKx9UxhKI1NEf1PqosrNPxG3bnSsr2UBVbNurlg==, } engines: { node: ">=20" } @@ -6875,12 +6880,6 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.14.0: - resolution: - { - integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==, - } - ajv@6.15.0: resolution: { @@ -7293,6 +7292,14 @@ packages: engines: { node: ">=6.0.0" } hasBin: true + baseline-browser-mapping@2.10.43: + resolution: + { + integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==, + } + engines: { node: ">=6.0.0" } + hasBin: true + bcryptjs@3.0.3: resolution: { @@ -7369,10 +7376,10 @@ packages: } engines: { node: ">=10" } - brace-expansion@1.1.14: + brace-expansion@1.1.16: resolution: { - integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==, + integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==, } brace-expansion@2.1.0: @@ -7417,11 +7424,13 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true - buffer-crc32@0.2.13: + browserslist@4.28.6: resolution: { - integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==, + integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==, } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + hasBin: true buffer-crc32@1.0.0: resolution: @@ -7536,6 +7545,12 @@ packages: integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==, } + caniuse-lite@1.0.30001805: + resolution: + { + integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==, + } + canvas-confetti@1.9.4: resolution: { @@ -8615,6 +8630,12 @@ packages: integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==, } + electron-to-chromium@1.5.392: + resolution: + { + integrity: sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==, + } + emoji-regex@10.6.0: resolution: { @@ -8787,13 +8808,6 @@ packages: integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==, } - es-object-atoms@1.1.1: - resolution: - { - integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==, - } - engines: { node: ">= 0.4" } - es-object-atoms@1.1.2: resolution: { @@ -8829,10 +8843,10 @@ packages: engines: { node: ">=18" } hasBin: true - esbuild@0.28.0: + esbuild@0.28.1: resolution: { - integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==, + integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==, } engines: { node: ">=18" } hasBin: true @@ -9414,10 +9428,10 @@ packages: } engines: { node: ">= 18" } - form-data@4.0.5: + form-data@4.0.6: resolution: { - integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==, + integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==, } engines: { node: ">= 6" } @@ -9744,13 +9758,6 @@ packages: } engines: { node: ">= 0.4" } - hasown@2.0.2: - resolution: - { - integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==, - } - engines: { node: ">= 0.4" } - hasown@2.0.4: resolution: { @@ -10511,13 +10518,6 @@ packages: integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, } - js-yaml@4.1.1: - resolution: - { - integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==, - } - hasBin: true - js-yaml@4.3.0: resolution: { @@ -12190,10 +12190,10 @@ packages: } hasBin: true - piscina@4.9.2: + piscina@4.9.3: resolution: { - integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==, + integrity: sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==, } pkg-conf@2.1.0: @@ -12454,10 +12454,10 @@ packages: } engines: { node: ">=6" } - qs@6.15.1: + qs@6.15.3: resolution: { - integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==, + integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==, } engines: { node: ">=0.6" } @@ -13065,13 +13065,6 @@ packages: } engines: { node: ">= 0.4" } - side-channel@1.1.0: - resolution: - { - integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==, - } - engines: { node: ">= 0.4" } - side-channel@1.1.1: resolution: { @@ -14435,7 +14428,7 @@ packages: peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 "@vitejs/devtools": ^0.3.0 - esbuild: ^0.27.0 || ^0.28.0 + esbuild: ^0.28.1 jiti: ">=1.21.0" less: ^4.0.0 sass: ^1.70.0 @@ -15095,10 +15088,10 @@ packages: } engines: { node: ^20.19.0 || ^22.12.0 || >=23 } - yauzl@3.3.0: + yauzl@3.4.0: resolution: { - integrity: sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==, + integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==, } engines: { node: ">=12" } @@ -16415,157 +16408,157 @@ snapshots: "@esbuild/aix-ppc64@0.25.12": optional: true - "@esbuild/aix-ppc64@0.28.0": + "@esbuild/aix-ppc64@0.28.1": optional: true "@esbuild/android-arm64@0.25.12": optional: true - "@esbuild/android-arm64@0.28.0": + "@esbuild/android-arm64@0.28.1": optional: true "@esbuild/android-arm@0.25.12": optional: true - "@esbuild/android-arm@0.28.0": + "@esbuild/android-arm@0.28.1": optional: true "@esbuild/android-x64@0.25.12": optional: true - "@esbuild/android-x64@0.28.0": + "@esbuild/android-x64@0.28.1": optional: true "@esbuild/darwin-arm64@0.25.12": optional: true - "@esbuild/darwin-arm64@0.28.0": + "@esbuild/darwin-arm64@0.28.1": optional: true "@esbuild/darwin-x64@0.25.12": optional: true - "@esbuild/darwin-x64@0.28.0": + "@esbuild/darwin-x64@0.28.1": optional: true "@esbuild/freebsd-arm64@0.25.12": optional: true - "@esbuild/freebsd-arm64@0.28.0": + "@esbuild/freebsd-arm64@0.28.1": optional: true "@esbuild/freebsd-x64@0.25.12": optional: true - "@esbuild/freebsd-x64@0.28.0": + "@esbuild/freebsd-x64@0.28.1": optional: true "@esbuild/linux-arm64@0.25.12": optional: true - "@esbuild/linux-arm64@0.28.0": + "@esbuild/linux-arm64@0.28.1": optional: true "@esbuild/linux-arm@0.25.12": optional: true - "@esbuild/linux-arm@0.28.0": + "@esbuild/linux-arm@0.28.1": optional: true "@esbuild/linux-ia32@0.25.12": optional: true - "@esbuild/linux-ia32@0.28.0": + "@esbuild/linux-ia32@0.28.1": optional: true "@esbuild/linux-loong64@0.25.12": optional: true - "@esbuild/linux-loong64@0.28.0": + "@esbuild/linux-loong64@0.28.1": optional: true "@esbuild/linux-mips64el@0.25.12": optional: true - "@esbuild/linux-mips64el@0.28.0": + "@esbuild/linux-mips64el@0.28.1": optional: true "@esbuild/linux-ppc64@0.25.12": optional: true - "@esbuild/linux-ppc64@0.28.0": + "@esbuild/linux-ppc64@0.28.1": optional: true "@esbuild/linux-riscv64@0.25.12": optional: true - "@esbuild/linux-riscv64@0.28.0": + "@esbuild/linux-riscv64@0.28.1": optional: true "@esbuild/linux-s390x@0.25.12": optional: true - "@esbuild/linux-s390x@0.28.0": + "@esbuild/linux-s390x@0.28.1": optional: true "@esbuild/linux-x64@0.25.12": optional: true - "@esbuild/linux-x64@0.28.0": + "@esbuild/linux-x64@0.28.1": optional: true "@esbuild/netbsd-arm64@0.25.12": optional: true - "@esbuild/netbsd-arm64@0.28.0": + "@esbuild/netbsd-arm64@0.28.1": optional: true "@esbuild/netbsd-x64@0.25.12": optional: true - "@esbuild/netbsd-x64@0.28.0": + "@esbuild/netbsd-x64@0.28.1": optional: true "@esbuild/openbsd-arm64@0.25.12": optional: true - "@esbuild/openbsd-arm64@0.28.0": + "@esbuild/openbsd-arm64@0.28.1": optional: true "@esbuild/openbsd-x64@0.25.12": optional: true - "@esbuild/openbsd-x64@0.28.0": + "@esbuild/openbsd-x64@0.28.1": optional: true "@esbuild/openharmony-arm64@0.25.12": optional: true - "@esbuild/openharmony-arm64@0.28.0": + "@esbuild/openharmony-arm64@0.28.1": optional: true "@esbuild/sunos-x64@0.25.12": optional: true - "@esbuild/sunos-x64@0.28.0": + "@esbuild/sunos-x64@0.28.1": optional: true "@esbuild/win32-arm64@0.25.12": optional: true - "@esbuild/win32-arm64@0.28.0": + "@esbuild/win32-arm64@0.28.1": optional: true "@esbuild/win32-ia32@0.25.12": optional: true - "@esbuild/win32-ia32@0.28.0": + "@esbuild/win32-ia32@0.28.1": optional: true "@esbuild/win32-x64@0.25.12": optional: true - "@esbuild/win32-x64@0.28.0": + "@esbuild/win32-x64@0.28.1": optional: true "@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))": @@ -16591,15 +16584,15 @@ snapshots: dependencies: "@types/json-schema": 7.0.15 - "@eslint/eslintrc@3.3.5": + "@eslint/eslintrc@3.3.6": dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -17147,7 +17140,7 @@ snapshots: "@intlify/message-compiler": 11.4.6 "@intlify/shared": 11.4.6 acorn: 8.17.0 - esbuild: 0.25.12 + esbuild: 0.28.1 escodegen: 2.1.0 estree-walker: 2.0.2 jsonc-eslint-parser: 2.4.2 @@ -17174,7 +17167,7 @@ snapshots: "@intlify/shared@11.4.6": {} - "@intlify/unplugin-vue-i18n@11.2.4(@vue/compiler-dom@3.5.39)(eslint@10.6.0(jiti@2.7.0))(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))": + "@intlify/unplugin-vue-i18n@11.2.4(@vue/compiler-dom@3.5.39)(eslint@10.6.0(jiti@2.7.0))(rollup@4.62.2)(typescript@6.0.3)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))": dependencies: "@eslint-community/eslint-utils": 4.9.1(eslint@10.6.0(jiti@2.7.0)) "@intlify/bundle-utils": 11.2.4(vue-i18n@11.4.6(vue@3.5.39(typescript@6.0.3))) @@ -17190,7 +17183,7 @@ snapshots: unplugin: 2.3.11 vue: 3.5.39(typescript@6.0.3) optionalDependencies: - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vue-i18n: 11.4.6(vue@3.5.39(typescript@6.0.3)) transitivePeerDependencies: - "@vue/compiler-dom" @@ -17339,7 +17332,7 @@ snapshots: "@tybys/wasm-util": 0.10.3 optional: true - "@nestjs/cli@11.0.23(@swc/cli@0.8.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(chokidar@4.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(esbuild@0.28.0)(prettier@3.9.5)": + "@nestjs/cli@11.0.23(@swc/cli@0.8.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(chokidar@4.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(esbuild@0.28.1)(prettier@3.9.5)": dependencies: "@angular-devkit/core": 19.2.27(chokidar@4.0.3) "@angular-devkit/schematics": 19.2.27(chokidar@4.0.3) @@ -17350,14 +17343,14 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)) glob: 13.0.6 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0) + webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1) webpack-node-externals: 3.0.0 optionalDependencies: "@swc/cli": 0.8.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(chokidar@4.0.3) @@ -17992,7 +17985,7 @@ snapshots: "@xhmikosr/bin-wrapper": 14.2.2 commander: 8.3.0 minimatch: 9.0.9 - piscina: 4.9.2 + piscina: 4.9.3 semver: 7.7.4 slash: 3.0.0 source-map: 0.7.6 @@ -18130,12 +18123,12 @@ snapshots: "@tailwindcss/oxide-win32-arm64-msvc": 4.3.2 "@tailwindcss/oxide-win32-x64-msvc": 4.3.2 - "@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))": + "@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))": dependencies: "@tailwindcss/node": 4.3.2 "@tailwindcss/oxide": 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) "@tanstack/table-core@8.21.3": {} @@ -18493,7 +18486,7 @@ snapshots: "@types/cookiejar": 2.1.5 "@types/methods": 1.1.4 "@types/node": 26.1.1 - form-data: 4.0.5 + form-data: 4.0.6 "@types/supertest@7.2.0": dependencies: @@ -18669,10 +18662,10 @@ snapshots: transitivePeerDependencies: - "@types/node" - "@vitejs/plugin-vue@6.0.7(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))": + "@vitejs/plugin-vue@6.0.7(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))": dependencies: "@rolldown/pluginutils": 1.0.1 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vue: 3.5.39(typescript@6.0.3) "@vitest/coverage-v8@4.1.10(vitest@4.1.10)": @@ -18687,7 +18680,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) "@vitest/eslint-plugin@1.6.22(@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.10)": dependencies: @@ -18697,7 +18690,7 @@ snapshots: optionalDependencies: "@typescript-eslint/eslint-plugin": 8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -18710,13 +18703,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - "@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))": + "@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))": dependencies: "@vitest/spy": 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) "@vitest/pretty-format@4.1.10": dependencies: @@ -19075,20 +19068,20 @@ snapshots: - react-native-b4a - supports-color - "@xhmikosr/decompress-unzip@8.1.0": + "@xhmikosr/decompress-unzip@8.2.1": dependencies: file-type: 21.3.4 get-stream: 9.0.1 - yauzl: 3.3.0 + yauzl: 3.4.0 transitivePeerDependencies: - supports-color - "@xhmikosr/decompress@11.1.1": + "@xhmikosr/decompress@11.1.3": dependencies: "@xhmikosr/decompress-tar": 9.0.1 "@xhmikosr/decompress-tarbz2": 9.0.1 "@xhmikosr/decompress-targz": 9.0.1 - "@xhmikosr/decompress-unzip": 8.1.0 + "@xhmikosr/decompress-unzip": 8.2.1 graceful-fs: 4.2.11 strip-dirs: 3.0.0 transitivePeerDependencies: @@ -19099,7 +19092,7 @@ snapshots: "@xhmikosr/downloader@16.1.1": dependencies: "@xhmikosr/archive-type": 8.0.1 - "@xhmikosr/decompress": 11.1.1 + "@xhmikosr/decompress": 11.1.3 content-disposition: 1.1.0 defaults: 2.0.2 ext-name: 5.0.0 @@ -19137,10 +19130,6 @@ snapshots: dependencies: acorn: 8.17.0 - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -19181,13 +19170,6 @@ snapshots: ajv: 8.20.0 fast-deep-equal: 3.1.3 - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -19408,6 +19390,8 @@ snapshots: baseline-browser-mapping@2.10.42: {} + baseline-browser-mapping@2.10.43: {} + bcryptjs@3.0.3: {} before-after-hook@4.0.0: {} @@ -19454,7 +19438,7 @@ snapshots: widest-line: 3.1.0 wrap-ansi: 7.0.0 - brace-expansion@1.1.14: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -19491,7 +19475,13 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.5) - buffer-crc32@0.2.13: {} + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.392 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) buffer-crc32@1.0.0: {} @@ -19554,6 +19544,8 @@ snapshots: caniuse-lite@1.0.30001803: {} + caniuse-lite@1.0.30001805: {} + canvas-confetti@1.9.4: {} chai@6.2.2: {} @@ -19818,7 +19810,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.5 + browserslist: 4.28.6 core-util-is@1.0.3: {} @@ -20051,7 +20043,7 @@ snapshots: dependencies: "@drizzle-team/brocli": 0.10.2 "@esbuild-kit/esm-loader": 2.6.5 - esbuild: 0.25.12 + esbuild: 0.28.1 tsx: 4.23.0 drizzle-orm@0.45.2(@types/pg@8.20.0)(mysql2@3.22.6(@types/node@26.1.1))(pg@8.22.0): @@ -20103,6 +20095,8 @@ snapshots: electron-to-chromium@1.5.389: {} + electron-to-chromium@1.5.392: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -20259,10 +20253,6 @@ snapshots: es-module-lexer@2.1.0: {} - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -20272,7 +20262,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.4: dependencies: @@ -20314,34 +20304,34 @@ snapshots: "@esbuild/win32-ia32": 0.25.12 "@esbuild/win32-x64": 0.25.12 - esbuild@0.28.0: + esbuild@0.28.1: 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 + "@esbuild/aix-ppc64": 0.28.1 + "@esbuild/android-arm": 0.28.1 + "@esbuild/android-arm64": 0.28.1 + "@esbuild/android-x64": 0.28.1 + "@esbuild/darwin-arm64": 0.28.1 + "@esbuild/darwin-x64": 0.28.1 + "@esbuild/freebsd-arm64": 0.28.1 + "@esbuild/freebsd-x64": 0.28.1 + "@esbuild/linux-arm": 0.28.1 + "@esbuild/linux-arm64": 0.28.1 + "@esbuild/linux-ia32": 0.28.1 + "@esbuild/linux-loong64": 0.28.1 + "@esbuild/linux-mips64el": 0.28.1 + "@esbuild/linux-ppc64": 0.28.1 + "@esbuild/linux-riscv64": 0.28.1 + "@esbuild/linux-s390x": 0.28.1 + "@esbuild/linux-x64": 0.28.1 + "@esbuild/netbsd-arm64": 0.28.1 + "@esbuild/netbsd-x64": 0.28.1 + "@esbuild/openbsd-arm64": 0.28.1 + "@esbuild/openbsd-x64": 0.28.1 + "@esbuild/openharmony-arm64": 0.28.1 + "@esbuild/sunos-x64": 0.28.1 + "@esbuild/win32-arm64": 0.28.1 + "@esbuild/win32-ia32": 0.28.1 + "@esbuild/win32-x64": 0.28.1 escalade@3.2.0: {} @@ -20452,8 +20442,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 espree@11.2.0: @@ -20710,7 +20700,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: "@babel/code-frame": 7.29.0 chalk: 4.1.2 @@ -20725,16 +20715,16 @@ snapshots: semver: 7.8.5 tapable: 2.3.3 typescript: 5.9.3 - webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0) + webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1) form-data-encoder@4.1.0: {} - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formidable@3.5.4: @@ -20808,12 +20798,12 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-own-enumerable-property-symbols@3.0.2: {} @@ -20821,7 +20811,7 @@ snapshots: get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@6.0.1: {} @@ -20948,10 +20938,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -21329,10 +21315,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -21710,7 +21692,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.16 minimatch@5.1.9: dependencies: @@ -22206,7 +22188,7 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 - piscina@4.9.2: + piscina@4.9.3: optionalDependencies: "@napi-rs/nice": 1.1.1 @@ -22363,9 +22345,10 @@ snapshots: punycode@2.3.1: {} - qs@6.15.1: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 quansync@0.2.11: {} @@ -22859,14 +22842,6 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - side-channel@1.1.1: dependencies: es-errors: 1.3.0 @@ -23164,11 +23139,11 @@ snapshots: cookiejar: 2.1.4 debug: 4.4.3 fast-safe-stringify: 2.1.1 - form-data: 4.0.5 + form-data: 4.0.6 formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.1 + qs: 6.15.3 transitivePeerDependencies: - supports-color @@ -23291,16 +23266,16 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 - terser-webpack-plugin@5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)): + terser-webpack-plugin@5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: "@jridgewell/trace-mapping": 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0) + webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1) optionalDependencies: "@swc/core": 1.15.43(@swc/helpers@0.5.23) - esbuild: 0.28.0 + esbuild: 0.28.1 terser@5.48.0: dependencies: @@ -23405,13 +23380,13 @@ snapshots: dependencies: typescript: 6.0.3 - ts-loader@9.6.2(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)): + ts-loader@9.6.2(typescript@6.0.3)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)): dependencies: chalk: 4.1.2 picomatch: 4.0.5 source-map: 0.7.6 typescript: 6.0.3 - webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0) + webpack: 5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1) ts-node@10.9.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@26.1.1)(typescript@6.0.3): dependencies: @@ -23454,7 +23429,7 @@ snapshots: tsx@4.23.0: dependencies: - esbuild: 0.28.0 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 @@ -23643,6 +23618,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -23664,17 +23645,17 @@ snapshots: vary@1.1.2: {} - vite-dev-rpc@2.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + vite-dev-rpc@2.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: birpc: 4.0.0 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) - vite-hot-client: 2.2.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite-hot-client: 2.2.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) - vite-hot-client@2.2.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + vite-hot-client@2.2.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) - vite-plugin-inspect@11.4.1(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + vite-plugin-inspect@11.4.1(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: ansis: 4.3.1 error-stack-parser-es: 1.0.5 @@ -23684,15 +23665,15 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) - vite-dev-rpc: 2.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite-dev-rpc: 2.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) - vite-plugin-pwa@1.3.0(@vite-pwa/assets-generator@1.0.2(@types/node@26.1.1))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(@vite-pwa/assets-generator@1.0.2(@types/node@26.1.1))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 tinyglobby: 0.2.17 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) workbox-build: 7.4.1 workbox-window: 7.4.1 optionalDependencies: @@ -23700,21 +23681,21 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.5(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): + vite-plugin-vue-devtools@8.1.5(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): dependencies: "@vue/devtools-core": 8.1.5(vue@3.5.39(typescript@6.0.3)) "@vue/devtools-kit": 8.1.5 "@vue/devtools-shared": 8.1.5 sirv: 3.0.2 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) - vite-plugin-inspect: 11.4.1(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) - vite-plugin-vue-inspector: 6.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite-plugin-inspect: 11.4.1(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + vite-plugin-vue-inspector: 6.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) transitivePeerDependencies: - "@nuxt/kit" - supports-color - vue - vite-plugin-vue-inspector@6.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + vite-plugin-vue-inspector@6.0.0(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: "@babel/core": 7.29.7 "@babel/plugin-proposal-decorators": 7.29.7(@babel/core@7.29.7) @@ -23725,11 +23706,11 @@ snapshots: "@vue/compiler-dom": 3.5.39 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0): + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -23738,17 +23719,17 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: "@types/node": 26.1.1 - esbuild: 0.28.0 + esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.49.0 tsx: 4.23.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: "@vitest/expect": 4.1.10 - "@vitest/mocker": 4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) + "@vitest/mocker": 4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0)) "@vitest/pretty-format": 4.1.10 "@vitest/runner": 4.1.10 "@vitest/snapshot": 4.1.10 @@ -23765,7 +23746,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 26.1.1 @@ -23811,7 +23792,7 @@ snapshots: "@vue/devtools-api": 6.6.4 vue: 3.5.39(typescript@6.0.3) - vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): + vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): dependencies: "@babel/generator": 8.0.0 "@vue-macros/common": 3.1.2(vue@3.5.39(typescript@6.0.3)) @@ -23834,7 +23815,7 @@ snapshots: optionalDependencies: "@vue/compiler-sfc": 3.5.39 pinia: 3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.0)(yaml@2.9.0) vue-sonner@2.0.9: {} @@ -23884,7 +23865,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0): + webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1): dependencies: "@types/eslint-scope": 3.7.7 "@types/estree": 1.0.9 @@ -23907,7 +23888,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.0)) + terser-webpack-plugin: 5.6.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)(webpack@5.106.2(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)) watchpack: 2.5.2 webpack-sources: 3.5.0 transitivePeerDependencies: @@ -24208,9 +24189,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 - yauzl@3.3.0: + yauzl@3.4.0: dependencies: - buffer-crc32: 0.2.13 pend: 1.2.0 yn@3.1.1: {} diff --git a/server/package.json b/server/package.json index 8fb672b4b..17541c900 100644 --- a/server/package.json +++ b/server/package.json @@ -102,7 +102,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.5", + "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^10.0.1", "@ffmpeg-installer/ffmpeg": "^1.1.0", "@ffprobe-installer/ffprobe": "^2.1.2", diff --git a/server/src/common/utils/content-disposition.utils.test.ts b/server/src/common/utils/content-disposition.utils.test.ts index ea08504ec..ed8854b66 100644 --- a/server/src/common/utils/content-disposition.utils.test.ts +++ b/server/src/common/utils/content-disposition.utils.test.ts @@ -30,4 +30,21 @@ describe('contentDispositionHeader', () => { `attachment; filename="ok-_-_.epub"; filename*=UTF-8''ok--.epub`, ); }); + + it('bounds oversized untrusted filenames before encoding', () => { + const header = contentDispositionHeader('attachment', `${'a'.repeat(10_000)}.epub`, 'download'); + const quotedFilename = header.match(/filename="([^"]+)"/)?.[1]; + + expect(quotedFilename).toHaveLength(1024); + expect(header.length).toBeLessThan(2200); + }); + + it('rejects non-string runtime values without iterating over an attacker-controlled length', () => { + const maliciousValue = { length: Number.MAX_SAFE_INTEGER }; + + expect(contentDispositionHeader('attachment', maliciousValue as never, 'download')).toBe(`attachment; filename="download"`); + expect(contentDispositionHeader('attachment', 'book.epub', maliciousValue as never)).toBe( + `attachment; filename="book.epub"; filename*=UTF-8''book.epub`, + ); + }); }); diff --git a/server/src/common/utils/content-disposition.utils.ts b/server/src/common/utils/content-disposition.utils.ts index 14ab46af2..16eefd0a4 100644 --- a/server/src/common/utils/content-disposition.utils.ts +++ b/server/src/common/utils/content-disposition.utils.ts @@ -1,21 +1,15 @@ type ContentDispositionType = 'attachment' | 'inline'; +const MAX_HEADER_FILENAME_CODE_UNITS = 1024; function stripLoneSurrogates(value: string): string { - let out = ''; - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(i + 1); - if (next >= 0xdc00 && next <= 0xdfff) { - out += value[i] + value[i + 1]; - i += 1; - } - continue; - } - if (code >= 0xdc00 && code <= 0xdfff) continue; - out += value[i]; - } - return out; + return Array.from(value, (character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && codePoint >= 0xd800 && codePoint <= 0xdfff ? '' : character; + }).join(''); +} + +function boundedHeaderValue(value: unknown): string { + return typeof value === 'string' ? value.slice(0, MAX_HEADER_FILENAME_CODE_UNITS) : ''; } function encodeFilenameStar(value: string): string | null { @@ -29,9 +23,11 @@ function encodeFilenameStar(value: string): string | null { } export function contentDispositionHeader(type: ContentDispositionType, filename: string, fallbackFilename: string): string { - const asciiFallback = fallbackFilename.replace(/[^\x20-\x7E]|["\\]/g, '_') || 'download'; - const asciiFilename = filename.replace(/[^\x20-\x7E]|["\\]/g, '_') || asciiFallback; - const encodedFilename = encodeFilenameStar(filename); + const boundedFilename = boundedHeaderValue(filename); + const boundedFallback = boundedHeaderValue(fallbackFilename); + const asciiFallback = boundedFallback.replace(/[^\x20-\x7E]|["\\]/g, '_') || 'download'; + const asciiFilename = boundedFilename.replace(/[^\x20-\x7E]|["\\]/g, '_') || asciiFallback; + const encodedFilename = encodeFilenameStar(boundedFilename); const disposition = `${type}; filename="${asciiFilename}"`; return encodedFilename ? `${disposition}; filename*=UTF-8''${encodedFilename}` : disposition; diff --git a/server/src/common/utils/html-to-text.utils.test.ts b/server/src/common/utils/html-to-text.utils.test.ts new file mode 100644 index 000000000..6808a3947 --- /dev/null +++ b/server/src/common/utils/html-to-text.utils.test.ts @@ -0,0 +1,15 @@ +import { htmlToPlainText } from './html-to-text.utils'; + +describe('htmlToPlainText', () => { + it('extracts text, decodes entities, and removes executable element contents', () => { + expect(htmlToPlainText('

Hello & world

')).toBe('Hello & world'); + }); + + it('removes entity-encoded tag-shaped markup without evaluating it', () => { + expect(htmlToPlainText('<script src="evil.js">alert(1)</script> safe')).toBe('alert(1) safe'); + }); + + it('preserves meaningful block and break boundaries when requested', () => { + expect(htmlToPlainText('

First
line

Second

', { preserveLineBreaks: true })).toBe('First\nline\nSecond'); + }); +}); diff --git a/server/src/common/utils/html-to-text.utils.ts b/server/src/common/utils/html-to-text.utils.ts new file mode 100644 index 000000000..081fbcbeb --- /dev/null +++ b/server/src/common/utils/html-to-text.utils.ts @@ -0,0 +1,47 @@ +import { load } from 'cheerio'; + +type HtmlToTextOptions = { + preserveLineBreaks?: boolean; +}; + +const BLOCK_ELEMENTS = + 'address,article,aside,blockquote,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hr,li,main,nav,ol,p,pre,section,table,tr,ul'; + +export function htmlToPlainText(html: string, options: HtmlToTextOptions = {}): string { + const $ = load(html, undefined, false); + $('script,style,noscript,template').remove(); + + if (options.preserveLineBreaks) { + $('br').replaceWith('\n'); + $(BLOCK_ELEMENTS).each((_index, element) => { + $(element).append('\n'); + }); + } + + const text = stripAngleBracketMarkup($.root().text()); + if (!options.preserveLineBreaks) return text.replace(/\s+/g, ' ').trim(); + + return text + .replace(/\r\n?/g, '\n') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n[ \t]+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function stripAngleBracketMarkup(value: string): string { + let result = ''; + let cursor = 0; + + while (cursor < value.length) { + const start = value.indexOf('<', cursor); + if (start < 0) return result + value.slice(cursor); + result += value.slice(cursor, start); + + const end = value.indexOf('>', start + 1); + if (end < 0) return result + value.slice(start).replace(/[<>]/g, ''); + cursor = end + 1; + } + + return result; +} diff --git a/server/src/common/utils/metadata-provider-hosts.utils.test.ts b/server/src/common/utils/metadata-provider-hosts.utils.test.ts new file mode 100644 index 000000000..c6507d9bd --- /dev/null +++ b/server/src/common/utils/metadata-provider-hosts.utils.test.ts @@ -0,0 +1,25 @@ +import { amazonOrigin, audibleApiOrigin, normalizeAmazonDomain, normalizeAudibleDomain } from './metadata-provider-hosts.utils'; + +describe('metadata provider hosts', () => { + it('resolves supported Amazon marketplaces to fixed HTTPS origins', () => { + expect(normalizeAmazonDomain(' AMAZON.CO.UK ')).toBe('amazon.co.uk'); + expect(amazonOrigin('amazon.co.uk')).toBe('https://www.amazon.co.uk'); + }); + + it('rejects arbitrary Amazon hosts', () => { + expect(normalizeAmazonDomain('localhost')).toBe('amazon.com'); + expect(amazonOrigin('amazon.com@127.0.0.1')).toBe('https://www.amazon.com'); + }); + + it('normalizes supported Audible host styles and rejects arbitrary suffixes', () => { + expect(normalizeAudibleDomain('https://api.audible.co.uk/path')).toBe('co.uk'); + expect(audibleApiOrigin('audible.co.uk')).toBe('https://api.audible.co.uk'); + expect(audibleApiOrigin('localhost')).toBe('https://api.audible.com'); + }); + + it('bounds hostile Audible input and removes paths without a regular expression', () => { + expect(normalizeAudibleDomain(`https://api.audible.co.uk/${'/'.repeat(100_000)}`)).toBe('co.uk'); + expect(normalizeAudibleDomain(`https://api.audible.com${'x'.repeat(10_000)}/path`)).toBe('com'); + expect(normalizeAudibleDomain({ length: Number.MAX_SAFE_INTEGER } as never)).toBe('com'); + }); +}); diff --git a/server/src/common/utils/metadata-provider-hosts.utils.ts b/server/src/common/utils/metadata-provider-hosts.utils.ts new file mode 100644 index 000000000..330751af6 --- /dev/null +++ b/server/src/common/utils/metadata-provider-hosts.utils.ts @@ -0,0 +1,66 @@ +const AMAZON_ORIGINS: Readonly> = { + 'amazon.com': 'https://www.amazon.com', + 'amazon.co.uk': 'https://www.amazon.co.uk', + 'amazon.de': 'https://www.amazon.de', + 'amazon.fr': 'https://www.amazon.fr', + 'amazon.it': 'https://www.amazon.it', + 'amazon.es': 'https://www.amazon.es', + 'amazon.ca': 'https://www.amazon.ca', + 'amazon.com.au': 'https://www.amazon.com.au', + 'amazon.co.jp': 'https://www.amazon.co.jp', + 'amazon.in': 'https://www.amazon.in', + 'amazon.com.br': 'https://www.amazon.com.br', + 'amazon.com.mx': 'https://www.amazon.com.mx', + 'amazon.nl': 'https://www.amazon.nl', + 'amazon.se': 'https://www.amazon.se', + 'amazon.pl': 'https://www.amazon.pl', + 'amazon.sg': 'https://www.amazon.sg', + 'amazon.ae': 'https://www.amazon.ae', + 'amazon.sa': 'https://www.amazon.sa', + 'amazon.tr': 'https://www.amazon.tr', +}; + +const AUDIBLE_API_ORIGINS: Readonly> = { + com: 'https://api.audible.com', + 'co.uk': 'https://api.audible.co.uk', + de: 'https://api.audible.de', + fr: 'https://api.audible.fr', + it: 'https://api.audible.it', + es: 'https://api.audible.es', + ca: 'https://api.audible.ca', + 'com.au': 'https://api.audible.com.au', + 'co.jp': 'https://api.audible.co.jp', + in: 'https://api.audible.in', +}; + +const MAX_PROVIDER_DOMAIN_LENGTH = 256; + +function normalizeProviderDomainInput(value: string | undefined | null): string { + return typeof value === 'string' ? value.slice(0, MAX_PROVIDER_DOMAIN_LENGTH).trim().toLowerCase() : ''; +} + +export function normalizeAmazonDomain(value: string | undefined | null): string { + const normalized = normalizeProviderDomainInput(value); + return AMAZON_ORIGINS[normalized] ? normalized : 'amazon.com'; +} + +export function amazonOrigin(value: string | undefined | null): string { + return AMAZON_ORIGINS[normalizeAmazonDomain(value)] ?? AMAZON_ORIGINS['amazon.com']; +} + +export function normalizeAudibleDomain(value: string | undefined | null): string { + let domain = normalizeProviderDomainInput(value); + if (domain.startsWith('https://')) domain = domain.slice('https://'.length); + else if (domain.startsWith('http://')) domain = domain.slice('http://'.length); + + if (domain.startsWith('api.audible.')) domain = domain.slice('api.audible.'.length); + else if (domain.startsWith('audible.')) domain = domain.slice('audible.'.length); + + const pathStart = domain.indexOf('/'); + if (pathStart !== -1) domain = domain.slice(0, pathStart); + return AUDIBLE_API_ORIGINS[domain] ? domain : 'com'; +} + +export function audibleApiOrigin(value: string | undefined | null): string { + return AUDIBLE_API_ORIGINS[normalizeAudibleDomain(value)] ?? AUDIBLE_API_ORIGINS.com; +} diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index bf0a67e8c..2ac9364a9 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -4695,6 +4695,9 @@ describe('BookService', () => { await expect(service.renameFile(fileId, { filename: '../new.epub' }, user)).rejects.toThrow(BadRequestException); await expect(service.renameFile(fileId, { filename: 'dir/new.epub' }, user)).rejects.toThrow(BadRequestException); + await expect(service.renameFile(fileId, { filename: 'bad\0name.epub' }, user)).rejects.toThrow(BadRequestException); + await expect(service.renameFile(fileId, { filename: `${'a'.repeat(256)}.epub` }, user)).rejects.toThrow(BadRequestException); + expect(rename).not.toHaveBeenCalled(); }); it('only updates db if filename is not provided or unchanged', async () => { diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index fe40e7969..3cf8d8d06 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -1341,12 +1341,21 @@ export class BookService { let newAbsolutePath = file.absolutePath; if (dto.filename && dto.filename !== basename(file.absolutePath)) { - if (dto.filename.includes('/') || dto.filename.includes('\\')) { - throw new BadRequestException('Filename cannot contain path separators'); + const safeFilename = basename(dto.filename); + if ( + safeFilename !== dto.filename || + safeFilename === '.' || + safeFilename === '..' || + safeFilename.includes('\\') || + safeFilename.includes('\0') || + Buffer.byteLength(safeFilename, 'utf8') > 255 + ) { + throw new BadRequestException('Filename is invalid'); } - newAbsolutePath = join(dirname(file.absolutePath), dto.filename); + newAbsolutePath = join(dirname(file.absolutePath), safeFilename); if (newAbsolutePath !== file.absolutePath) { try { + // codeql[js/path-injection] basename constrains the user-provided value to one validated filename segment. await rename(file.absolutePath, newAbsolutePath); } catch (err) { throw new BadRequestException(`Failed to rename file on disk: ${err instanceof Error ? err.message : String(err)}`); diff --git a/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts b/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts index 0f39afd63..229e71b59 100644 --- a/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts +++ b/server/src/modules/custom-icon/custom-icon.sanitizer.test.ts @@ -46,6 +46,12 @@ describe('sanitizeSvgIcon', () => { expect(sanitized).not.toContain('style='); }); + it('rejects comments containing script-like markup', () => { + expect(() => sanitizeSvgIcon(Buffer.from(''))).toThrow( + BadRequestException, + ); + }); + it('rejects doctype subsets', () => { expect(() => sanitizeSvgIcon(Buffer.from(' ]>'))).toThrow( BadRequestException, diff --git a/server/src/modules/custom-icon/custom-icon.sanitizer.ts b/server/src/modules/custom-icon/custom-icon.sanitizer.ts index f013ea859..3249c771f 100644 --- a/server/src/modules/custom-icon/custom-icon.sanitizer.ts +++ b/server/src/modules/custom-icon/custom-icon.sanitizer.ts @@ -198,7 +198,6 @@ function stripSafePreamble(value: string): string { return value .replace(/^\uFEFF/, '') .replace(/<\?xml[^>]*\?>/gi, '') - .replace(//g, '') .replace(/]*>/gi, '') .trim(); } diff --git a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts index 8e99dd21d..d3c06eed3 100644 --- a/server/src/modules/file-write/formats/cbx/comic-info-builder.ts +++ b/server/src/modules/file-write/formats/cbx/comic-info-builder.ts @@ -2,6 +2,7 @@ import { XMLParser, XMLBuilder } from 'fast-xml-parser'; import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface'; import { COMIC_INFO_MANAGED_NOTES_KEYS, COMIC_INFO_PROVIDER_ID_KEYS, COMIC_INFO_PROVIDER_WEB_URL_BUILDERS } from '../../file-write.constants'; +import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils'; type ComicInfoObject = Record; @@ -36,7 +37,7 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite const info: ComicInfoObject = existingXml ? parseComicInfoXml(existingXml) : {}; setComicInfoField(info, fieldMask, 'title', 'Title', payload.title); - setComicInfoField(info, fieldMask, 'description', 'Summary', payload.description, stripHtml); + setComicInfoField(info, fieldMask, 'description', 'Summary', payload.description, htmlToPlainText); setComicInfoField(info, fieldMask, 'publisher', 'Publisher', payload.publisher); setComicInfoField(info, fieldMask, 'seriesName', 'Series', payload.seriesName); setIssueNumberField(info, fieldMask, payload); @@ -89,18 +90,6 @@ export function buildComicInfoXml(existingXml: string | null, payload: BookWrite return `\n${xmlBody}`; } -function stripHtml(html: string): string { - return html - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/<[^>]*>/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim(); -} - function formatSeriesIndex(val: number): string { return val % 1 === 0 ? String(Math.trunc(val)) : String(val); } diff --git a/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts b/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts index 3818acbce..e168d2496 100644 --- a/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts +++ b/server/src/modules/metadata-fetch/providers/aladin/aladin.mapper.ts @@ -1,5 +1,6 @@ import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types'; import { parsePublishedDateKey, parsePublishedYear, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils'; +import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils'; import { AladinItem } from './aladin.types'; function parseAuthors(authorString: string): string[] { @@ -22,7 +23,7 @@ function parsePageCount(subInfo?: AladinItem['subInfo']): number | undefined { function parseDescription(description: string, fullDescription?: string): string | undefined { const desc = fullDescription ?? description; if (!desc) return undefined; - return desc.replace(/<[^>]*>/g, '').trim(); + return htmlToPlainText(desc) || undefined; } function parseGenres(categoryIdList?: AladinItem['categoryIdList']): string[] | undefined { diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts index 25da7ee4c..77ea58005 100644 --- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts +++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts @@ -3,6 +3,7 @@ import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types'; import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils'; +import { amazonOrigin } from '../../../../common/utils/metadata-provider-hosts.utils'; import { fetchWithThrottle } from '../../fetch-with-throttle'; import { ProviderThrottleError } from '../../provider-throttle.error'; import { IdentifiableProvider } from '../metadata-provider'; @@ -60,14 +61,14 @@ export class AmazonProvider implements IdentifiableProvider { private async searchAsins(params: MetadataSearchParams, domain: string, cookie: string, limit: number, signal?: AbortSignal): Promise { const query = params.isbn?.trim() || [params.title, params.author].filter(Boolean).join(' '); if (!query) return []; - const url = `https://www.${domain}/s?k=${encodeURIComponent(query)}&i=stripbooks`; + const url = `${amazonOrigin(domain)}/s?k=${encodeURIComponent(query)}&i=stripbooks`; const html = await this.fetchHtml(url, cookie, 'search', query, undefined, signal); return html ? extractAsins(html, limit) : []; } private async fetchByAsin(asin: string, domain: string, cookie: string, signal?: AbortSignal): Promise { - const url = `https://www.${domain}/dp/${asin}`; - const html = await this.fetchHtml(url, cookie, 'lookup', undefined, asin, signal); + const url = new URL(`/dp/${encodeURIComponent(asin)}`, amazonOrigin(domain)); + const html = await this.fetchHtml(url.toString(), cookie, 'lookup', undefined, asin, signal); if (!html) return null; const data = parseBookPage(html); if (!data.title) return null; @@ -89,7 +90,7 @@ export class AmazonProvider implements IdentifiableProvider { seriesIndex: data.seriesIndex, coverUrl: data.coverUrl, genres: data.tags?.length ? data.tags : undefined, - sourceUrl: `https://www.${domain}/dp/${asin}`, + sourceUrl: url.toString(), ...(data.communityRating !== undefined ? { communityRating: data.communityRating } : {}), ...(data.communityRatingCount !== undefined ? { communityRatingCount: data.communityRatingCount } : {}), }; diff --git a/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts b/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts index 406d3a7eb..7c3d64353 100644 --- a/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts +++ b/server/src/modules/metadata-fetch/providers/audible/audible.provider.ts @@ -3,6 +3,7 @@ import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types'; import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils'; +import { audibleApiOrigin } from '../../../../common/utils/metadata-provider-hosts.utils'; import { fetchWithThrottle } from '../../fetch-with-throttle'; import { ProviderThrottleError } from '../../provider-throttle.error'; import { IdentifiableProvider } from '../metadata-provider'; @@ -31,7 +32,7 @@ export class AudibleProvider implements IdentifiableProvider { const query = this.buildQuery(params); if (!query) return []; - const url = new URL(`https://api.audible.${normalizedDomain}/1.0/catalog/products`); + const url = new URL('/1.0/catalog/products', audibleApiOrigin(normalizedDomain)); url.searchParams.set('num_results', '10'); url.searchParams.set('keywords', query); url.searchParams.set('response_groups', 'product_desc,media,product_attrs,series,product_plan_details,category_ladders,rating'); @@ -71,7 +72,7 @@ export class AudibleProvider implements IdentifiableProvider { if (!enabled) return null; const normalizedDomain = normalizeAudibleDomain(domain); - const url = new URL(`https://api.audible.${normalizedDomain}/1.0/catalog/products/${providerId}`); + const url = new URL(`/1.0/catalog/products/${encodeURIComponent(providerId)}`, audibleApiOrigin(normalizedDomain)); url.searchParams.set('response_groups', 'product_desc,media,product_attrs,series,product_plan_details,category_ladders,rating'); const requestUrl = url.toString(); const startedAt = Date.now(); diff --git a/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts b/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts index dcb318239..96e6c7f66 100644 --- a/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts +++ b/server/src/modules/metadata-fetch/providers/audible/normalize-audible-domain.ts @@ -1,11 +1 @@ -export function normalizeAudibleDomain(value: string | undefined | null): string { - let domain = (value ?? '').trim().toLowerCase(); - if (!domain) return 'com'; - - domain = domain.replace(/^https?:\/\//, ''); - domain = domain.replace(/^api\.audible\./, ''); - domain = domain.replace(/^audible\./, ''); - domain = domain.replace(/\/.*$/, ''); - - return domain || 'com'; -} +export { normalizeAudibleDomain } from '../../../../common/utils/metadata-provider-hosts.utils'; diff --git a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts index 89ccdb8e5..3d7a164ae 100644 --- a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts +++ b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.mapper.test.ts @@ -64,7 +64,7 @@ describe('mapAudNexusBook — basic fields', () => { it('strips HTML from summary when description is absent', () => { const result = mapAudNexusBook(makeBook({ summary: '

A lone astronaut.

' })); - expect(result.description).toBe('A lone astronaut .'); + expect(result.description).toBe('A lone astronaut.'); }); it('maps publisherName', () => { diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts index 720457ec3..3e8fa8123 100644 --- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts +++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts @@ -8,6 +8,7 @@ import { GoodreadsAutocompleteItem, } from './goodreads.types'; import { parsePublishedDateFromEpochMillis, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils'; +import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils'; export function mapGoodreadsApolloState(state: Record, bookId: string): MetadataCandidate | null { const book = findBook(state, bookId); @@ -106,17 +107,7 @@ function parseSeriesFromTitle(title: string | undefined): { seriesName?: string; function extractAutocompleteDescription(description: GoodreadsAutocompleteItem['description']): string | undefined { const html = typeof description === 'string' ? description : description?.html; if (!html) return undefined; - const text = html - .replace(//gi, '\n') - .replace(/<[^>]+>/g, '') - .replace(/"/g, '"') - .replace(/&(?:#39|#x27|apos);/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); + const text = htmlToPlainText(html, { preserveLineBreaks: true }); return text || undefined; } diff --git a/server/src/modules/metadata-fetch/providers/provider-utils.ts b/server/src/modules/metadata-fetch/providers/provider-utils.ts index 0958d2a33..b18737caa 100644 --- a/server/src/modules/metadata-fetch/providers/provider-utils.ts +++ b/server/src/modules/metadata-fetch/providers/provider-utils.ts @@ -1,10 +1,4 @@ -const HTML_ENTITY_REPLACEMENTS: Array<[RegExp, string]> = [ - [/&/g, '&'], - [/</g, '<'], - [/>/g, '>'], - [/"/g, '"'], - [/'/g, "'"], -]; +import { htmlToPlainText } from '../../../common/utils/html-to-text.utils'; export function normalizeMaxCandidates(value: number | undefined, maxResults: number): number { if (!Number.isFinite(value) || value == null) return maxResults; @@ -42,11 +36,7 @@ export function sleep(ms: number, signal?: AbortSignal): Promise { } export function stripHtml(html: string): string { - let value = html.replace(/<[^>]*>/g, ' '); - for (const [pattern, replacement] of HTML_ENTITY_REPLACEMENTS) { - value = value.replace(pattern, replacement); - } - return value.replace(/\s+/g, ' ').trim(); + return htmlToPlainText(html); } export function sanitizeLogError(error: unknown): string { diff --git a/server/src/modules/metadata-preferences/provider-config.service.test.ts b/server/src/modules/metadata-preferences/provider-config.service.test.ts index 2ab24119a..43e258d19 100644 --- a/server/src/modules/metadata-preferences/provider-config.service.test.ts +++ b/server/src/modules/metadata-preferences/provider-config.service.test.ts @@ -488,6 +488,19 @@ describe('ProviderConfigService', () => { expect(result.message).toContain('bot-check'); }); + it('falls back to the allowlisted Amazon origin for an untrusted domain', async () => { + db.query.appSettings.findFirst.mockResolvedValue(undefined); + fetchMock.mockResolvedValue(new Response('Amazon results', { status: 200 })); + + await service.testProvider(MetadataProviderKey.AMAZON, { + amazon: { domain: 'localhost' }, + }); + + const requestUrl = fetchMock.mock.calls[0]?.[0]; + expect(requestUrl).toBeInstanceOf(URL); + expect((requestUrl as URL).hostname).toBe('www.amazon.com'); + }); + it('rejects unsupported provider test requests', async () => { await expect(service.testProvider(MetadataProviderKey.GOODREADS, {})).rejects.toThrow('Provider test not supported'); }); diff --git a/server/src/modules/metadata-preferences/provider-config.service.ts b/server/src/modules/metadata-preferences/provider-config.service.ts index ccf4f6d6c..b14bc986a 100644 --- a/server/src/modules/metadata-preferences/provider-config.service.ts +++ b/server/src/modules/metadata-preferences/provider-config.service.ts @@ -5,6 +5,7 @@ import { MetadataProviderKey, ProviderConfigurations, ProviderConnectionTestResu import { stripBearerPrefix, toBearerAuthorization } from '../../common/utils/bearer-token.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { amazonOrigin, normalizeAmazonDomain, normalizeAudibleDomain } from '../../common/utils/metadata-provider-hosts.utils'; import { DB } from '../../db'; import * as schema from '../../db/schema'; @@ -274,7 +275,7 @@ export class ProviderConfigService { google: { ...config.google, apiKey: googleApiKey }, amazon: { ...config.amazon, - domain: this.normalizeDomain(config.amazon.domain, DEFAULT_CONFIG.amazon.domain), + domain: normalizeAmazonDomain(config.amazon.domain), cookie: this.normalizeAmazonCookie(config.amazon.cookie), }, hardcover: { @@ -283,7 +284,7 @@ export class ProviderConfigService { }, audible: { ...config.audible, - domain: this.normalizeDomain(config.audible.domain, DEFAULT_CONFIG.audible.domain), + domain: this.normalizeAudibleConfigDomain(config.audible.domain), }, comicvine: { ...config.comicvine, @@ -307,11 +308,6 @@ export class ProviderConfigService { }; } - private normalizeDomain(value: string, fallback: string): string { - const normalized = value.trim().toLowerCase(); - return normalized || fallback; - } - private normalizeAmazonCookie(cookie: string): string { const normalized = cookie.trim(); if (!normalized) return ''; @@ -321,6 +317,13 @@ export class ProviderConfigService { return normalized; } + private normalizeAudibleConfigDomain(value: string): string { + const normalized = value.trim().toLowerCase(); + const suffix = normalizeAudibleDomain(normalized); + if (normalized === suffix || normalized === `audible.${suffix}`) return normalized; + return DEFAULT_CONFIG.audible.domain; + } + private normalizeKoboPathSegment(value: string, fallback: string): string { const normalized = value .trim() @@ -517,9 +520,11 @@ export class ProviderConfigService { } private async testAmazonProvider(config: ProviderConfigurations['amazon']): Promise { - const domain = this.normalizeDomain(config.domain, DEFAULT_CONFIG.amazon.domain); + const domain = normalizeAmazonDomain(config.domain); const cookie = this.normalizeAmazonCookie(config.cookie); - const url = `https://www.${domain}/s?k=${encodeURIComponent(AMAZON_TEST_QUERY)}&i=stripbooks`; + const url = new URL('/s', amazonOrigin(domain)); + url.searchParams.set('k', AMAZON_TEST_QUERY); + url.searchParams.set('i', 'stripbooks'); const headers: HeadersInit = cookie ? { ...AMAZON_TEST_HEADERS, cookie } : AMAZON_TEST_HEADERS; const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(PROVIDER_TEST_TIMEOUT_MS) }); diff --git a/server/src/modules/metadata/lib/epub-isbn-scan.ts b/server/src/modules/metadata/lib/epub-isbn-scan.ts index 3e85a355d..ecfb066fe 100644 --- a/server/src/modules/metadata/lib/epub-isbn-scan.ts +++ b/server/src/modules/metadata/lib/epub-isbn-scan.ts @@ -3,6 +3,7 @@ import { XMLParser } from 'fast-xml-parser'; import { attr, findInZip, toRecordArray } from './epub-zip-utils'; import { findIsbnInText, pickBestIsbn } from './isbn-detect'; +import { htmlToPlainText } from '../../../common/utils/html-to-text.utils'; const SPINE_WINDOW_FRONT = 10; const SPINE_WINDOW_BACK = 5; @@ -22,13 +23,6 @@ interface IsbnResult { const EMPTY: IsbnResult = { isbn10: null, isbn13: null }; -function stripTags(html: string): string { - return html - .replace(//gi, ' ') - .replace(//gi, ' ') - .replace(/<[^>]+>/g, ' '); -} - async function readEntryCapped(file: unzipper.File): Promise { const size = file.uncompressedSize ?? 0; if (size === 0 || size <= PER_DOC_BYTE_CAP) { @@ -92,7 +86,7 @@ export async function scanEpubSpineForIsbn(zip: unzipper.CentralDirectory, opfXm for (const doc of docs) { let text: string; try { - text = stripTags(await readEntryCapped(doc)); + text = htmlToPlainText(await readEntryCapped(doc)); } catch { continue; } diff --git a/server/src/modules/metadata/lib/mobi-parser.ts b/server/src/modules/metadata/lib/mobi-parser.ts index 09d3d7e68..a324e5d02 100644 --- a/server/src/modules/metadata/lib/mobi-parser.ts +++ b/server/src/modules/metadata/lib/mobi-parser.ts @@ -1,5 +1,7 @@ import { readFile } from 'fs/promises'; +import { htmlToPlainText } from '../../../common/utils/html-to-text.utils'; + // ── PalmDB / MOBI binary offsets ────────────────────────────────────────────── // // PalmDB header (file start): @@ -108,10 +110,6 @@ function exthUint32(records: ExthRecord[], type: number): number | null { return rec.data.readUInt32BE(0); } -function stripHtml(html: string): string { - return html.replace(/<[^>]*>/g, '').trim(); -} - export function parseMobiBuffer(buf: Buffer): MobiParsed { const recordOffsets = readRecords(buf); if (recordOffsets.length === 0) throw new Error('No records in PalmDB'); @@ -146,7 +144,7 @@ export function parseMobiBuffer(buf: Buffer): MobiParsed { const authors = exthStrings(exthRecords, EXTH.AUTHOR); const publisher = exthString(exthRecords, EXTH.PUBLISHER); const rawDescription = exthString(exthRecords, EXTH.DESCRIPTION); - const description = rawDescription ? stripHtml(rawDescription) : null; + const description = rawDescription ? htmlToPlainText(rawDescription) : null; const isbn = exthString(exthRecords, EXTH.ISBN); const tags = exthStrings(exthRecords, EXTH.SUBJECT); const publishedDate = exthString(exthRecords, EXTH.PUBLISHED_DATE); diff --git a/server/src/modules/migration/executor/user-state.importer.test.ts b/server/src/modules/migration/executor/user-state.importer.test.ts index 505122bfb..8204476f9 100644 --- a/server/src/modules/migration/executor/user-state.importer.test.ts +++ b/server/src/modules/migration/executor/user-state.importer.test.ts @@ -90,7 +90,7 @@ describe('UserStateImporter', () => { ], readingSessions: [ { - sourceSessionId: 'session-1', + sourceSessionId: 'session-'.repeat(20), sourceUserId: 'u1', sourceBookId: 'b-source', bookType: 'EPUB', @@ -173,7 +173,7 @@ describe('UserStateImporter', () => { userId: 10, bookId: 200, bookFileId: 500, - sessionId: 'booklore:rs:session-1', + sessionId: expect.stringMatching(/^booklore:rs:[A-Za-z0-9_-]{43}$/), durationSeconds: 1800, progressDelta: 12.35, endProgress: 100, diff --git a/server/src/modules/migration/executor/user-state.importer.ts b/server/src/modules/migration/executor/user-state.importer.ts index 87d9f2c2d..33231e8a8 100644 --- a/server/src/modules/migration/executor/user-state.importer.ts +++ b/server/src/modules/migration/executor/user-state.importer.ts @@ -774,7 +774,8 @@ function buildImportedReadingSessionId(sourceType: string, sourceSessionId: stri const raw = sourceSessionId.trim(); const candidate = `${prefix}${raw}`; if (candidate.length <= 64) return candidate; - return `${prefix}${createHash('sha1').update(raw).digest('hex')}`; + const digest = createHash('sha256').update(sourceType).update('\0').update(raw).digest('base64url'); + return `${prefix}${digest.slice(0, 64 - prefix.length)}`; } function sanitizeNullablePercent(value: number | null): number | null { diff --git a/server/src/modules/path/path-policy.service.test.ts b/server/src/modules/path/path-policy.service.test.ts index c70148d6d..95725813e 100644 --- a/server/src/modules/path/path-policy.service.test.ts +++ b/server/src/modules/path/path-policy.service.test.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mkdtemp, mkdir, rm, symlink } from 'fs/promises'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; @@ -43,6 +43,25 @@ describe('PathPolicyService', () => { await expect(service.isWithinBrowseRoot(sibling)).resolves.toBe(false); }); + it('rejects normalized traversal paths before filesystem access', async () => { + const parent = await makeTempRoot(); + const root = join(parent, 'books'); + await mkdir(root); + const service = makeService(root); + + await expect(service.assertWithinBrowseRoot(join(root, '..', 'outside'))).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.isWithinBrowseRoot(join(root, '..', 'outside'))).resolves.toBe(false); + }); + + it('rejects oversized paths before resolving or accessing them', async () => { + const root = await makeTempRoot(); + const service = makeService(root); + const oversizedPath = `/${'a'.repeat(4097)}`; + + await expect(service.resolveBrowsePath(oversizedPath)).rejects.toBeInstanceOf(BadRequestException); + await expect(service.isWithinBrowseRoot(oversizedPath)).resolves.toBe(false); + }); + it('rejects paths that escape through a symlinked existing ancestor', async () => { const parent = await makeTempRoot(); const root = join(parent, 'books'); @@ -55,6 +74,15 @@ describe('PathPolicyService', () => { await expect(service.assertWithinBrowseRoot(join(root, 'escape', 'new-library'))).rejects.toBeInstanceOf(ForbiddenException); }); + it('fails closed when a path cannot be canonicalized safely', async () => { + const root = await makeTempRoot(); + await symlink('loop', join(root, 'loop')); + const service = makeService(root); + + await expect(service.assertWithinBrowseRoot(join(root, 'loop'))).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.isWithinBrowseRoot(join(root, 'loop'))).resolves.toBe(false); + }); + async function makeTempRoot(): Promise { const path = await mkdtemp(join(tmpdir(), 'bookorbit-path-policy-')); tempRoots.push(path); diff --git a/server/src/modules/path/path-policy.service.ts b/server/src/modules/path/path-policy.service.ts index 71459e199..88ee174ae 100644 --- a/server/src/modules/path/path-policy.service.ts +++ b/server/src/modules/path/path-policy.service.ts @@ -1,10 +1,12 @@ import type { ConfigType } from '@nestjs/config'; -import { Inject, Injectable, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { realpath } from 'fs/promises'; -import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'; import { storageConfig } from '../../config/config'; +const MAX_BROWSE_PATH_LENGTH = 4096; + @Injectable() export class PathPolicyService { private readonly browseRoot: string; @@ -19,7 +21,10 @@ export class PathPolicyService { } async resolveBrowsePath(rawPath?: string | null): Promise { - const path = resolve(rawPath?.trim() || this.browseRoot); + const inputPath = typeof rawPath === 'string' ? rawPath.trim() : ''; + if (inputPath.length > MAX_BROWSE_PATH_LENGTH) throw new BadRequestException('Path is too long'); + + const path = resolve(inputPath || this.browseRoot); await this.assertWithinBrowseRoot(path); return path; } @@ -33,8 +38,39 @@ export class PathPolicyService { } async isWithinBrowseRoot(rawPath: string): Promise { - const [root, path] = await Promise.all([this.getCanonicalBrowseRoot(), canonicalizeWithExistingAncestor(resolve(rawPath))]); - return isSameOrChildPath(root, path); + if (typeof rawPath !== 'string' || rawPath.length > MAX_BROWSE_PATH_LENGTH) return false; + + const resolvedPath = resolve(rawPath); + const lexicalRelativePath = relative(this.browseRoot, resolvedPath); + if (lexicalRelativePath === '..' || lexicalRelativePath.startsWith(`..${sep}`) || isAbsolute(lexicalRelativePath)) return false; + + const suffixSegments: string[] = []; + let current = resolvedPath; + let canonicalPath: string; + + while (true) { + try { + const canonicalAncestor = await realpath(current); + canonicalPath = suffixSegments.reduceRight((built, segment) => join(built, segment), canonicalAncestor); + break; + } catch (error) { + const code = getErrorCode(error); + if (code !== 'ENOENT' && code !== 'ENOTDIR') return false; + } + + const parent = dirname(current); + if (parent === current) return false; + + suffixSegments.push(basename(current)); + current = parent; + } + + const root = await this.getCanonicalBrowseRoot(); + const canonicalRelativePath = relative(root, canonicalPath); + return ( + canonicalRelativePath === '' || + (!canonicalRelativePath.startsWith(`..${sep}`) && canonicalRelativePath !== '..' && !isAbsolute(canonicalRelativePath)) + ); } private getCanonicalBrowseRoot(): Promise { @@ -49,7 +85,7 @@ async function canonicalizeWithExistingAncestor(resolvedPath: string): Promise join(built, segment), canonical); } catch (error) { const code = getErrorCode(error); @@ -66,11 +102,6 @@ async function canonicalizeWithExistingAncestor(resolvedPath: string): Promise { const pathPolicy = { - getBrowseRoot: vi.fn(() => '/books'), - resolveBrowsePath: vi.fn((path?: string) => Promise.resolve(path || '/books')), + getBrowseRoot: vi.fn(() => '/tmp/books'), + resolveBrowsePath: vi.fn((path?: string) => Promise.resolve(path || '/tmp/books')), assertWithinBrowseRoot: vi.fn((path: string) => Promise.resolve(path)), }; let service: PathService; beforeEach(() => { vi.resetAllMocks(); - pathPolicy.getBrowseRoot.mockReturnValue('/books'); - pathPolicy.resolveBrowsePath.mockImplementation((path?: string) => Promise.resolve(path || '/books')); + pathPolicy.getBrowseRoot.mockReturnValue('/tmp/books'); + pathPolicy.resolveBrowsePath.mockImplementation((path?: string) => Promise.resolve(path || '/tmp/books')); pathPolicy.assertWithinBrowseRoot.mockImplementation((path: string) => Promise.resolve(path)); service = new PathService(pathPolicy as never); }); it('returns the configured browse root in config', () => { - expect(service.getConfig()).toEqual({ root: '/books' }); + expect(service.getConfig()).toEqual({ root: '/tmp/books' }); }); it('returns empty for blocked system paths', async () => { @@ -60,7 +60,7 @@ describe('PathService', () => { await expect(service.listDirectories()).resolves.toEqual([]); expect(pathPolicy.resolveBrowsePath).toHaveBeenCalledWith(undefined); - expect(lstatMock).toHaveBeenCalledWith('/books'); + expect(lstatMock).toHaveBeenCalledWith('/tmp/books'); }); it('rejects symlinked root paths', async () => { @@ -70,42 +70,27 @@ describe('PathService', () => { }); it('lists only accessible real (non-symlink) directories and sorts them by name', async () => { - lstatMock.mockImplementation((fullPath) => { - if (String(fullPath) === '/tmp/books') { - return Promise.resolve({ isSymbolicLink: () => false, isDirectory: () => true } as never); - } - if (String(fullPath).endsWith('/beta')) { - throw Object.assign(new Error('denied'), { code: 'EACCES' }); - } - if (String(fullPath).endsWith('/alpha-link')) { - return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => true } as never); - } - return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => false } as never); - }); + lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => true } as never); readdirMock.mockResolvedValue([ entry('notes.txt'), entry('.cache', { directory: true }), entry('zeta', { directory: true }), - entry('alpha-link', { directory: true }), + entry('alpha-link', { directory: true, symbolicLink: true }), entry('beta', { directory: true }), ] as never); - await expect(service.listDirectories('/tmp/books')).resolves.toEqual([{ name: 'zeta', path: '/tmp/books/zeta' }]); + await expect(service.listDirectories('/tmp/books')).resolves.toEqual([ + { name: 'beta', path: '/tmp/books/beta' }, + { name: 'zeta', path: '/tmp/books/zeta' }, + ]); + expect(lstatMock).toHaveBeenCalledTimes(1); }); it('excludes entries that appear as directories but are symlinks when lstat confirms it', async () => { - lstatMock.mockImplementation((fullPath) => { - if (String(fullPath) === '/tmp/books') { - return Promise.resolve({ isSymbolicLink: () => false, isDirectory: () => true } as never); - } - if (String(fullPath).endsWith('/real-dir')) { - return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => false } as never); - } - return Promise.resolve({ isDirectory: () => true, isSymbolicLink: () => true } as never); - }); + lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => true } as never); - readdirMock.mockResolvedValue([entry('real-dir', { directory: true }), entry('sym-dir', { directory: true })] as never); + readdirMock.mockResolvedValue([entry('real-dir', { directory: true }), entry('sym-dir', { directory: true, symbolicLink: true })] as never); await expect(service.listDirectories('/tmp/books')).resolves.toEqual([{ name: 'real-dir', path: '/tmp/books/real-dir' }]); }); @@ -121,7 +106,16 @@ describe('PathService', () => { lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => true } as never); readdirMock.mockRejectedValue(new Error('missing')); - await expect(service.listDirectories('/does/not/exist')).resolves.toEqual([]); + await expect(service.listDirectories('/tmp/books/does/not/exist')).resolves.toEqual([]); + }); + + it('rechecks browse-root containment before accessing the filesystem', async () => { + pathPolicy.getBrowseRoot.mockReturnValue('/books'); + pathPolicy.resolveBrowsePath.mockResolvedValue('/etc'); + + await expect(service.listDirectories('/books/../../etc')).resolves.toEqual([]); + expect(lstatMock).not.toHaveBeenCalled(); + expect(readdirMock).not.toHaveBeenCalled(); }); describe('createDirectory', () => { @@ -164,14 +158,14 @@ describe('PathService', () => { it('rejects a symlinked parent', async () => { lstatMock.mockResolvedValue({ isSymbolicLink: () => true, isDirectory: () => false } as never); - await expect(service.createDirectory('/tmp/link', 'scifi')).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.createDirectory('/tmp/books/link', 'scifi')).rejects.toBeInstanceOf(ForbiddenException); expect(mkdirMock).not.toHaveBeenCalled(); }); it('rejects a parent that is not a directory', async () => { lstatMock.mockResolvedValue({ isSymbolicLink: () => false, isDirectory: () => false } as never); - await expect(service.createDirectory('/tmp/file.txt', 'scifi')).rejects.toBeInstanceOf(BadRequestException); + await expect(service.createDirectory('/tmp/books/file.txt', 'scifi')).rejects.toBeInstanceOf(BadRequestException); expect(mkdirMock).not.toHaveBeenCalled(); }); @@ -205,7 +199,16 @@ describe('PathService', () => { it('maps a missing parent (ENOENT) to a bad request', async () => { lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); - await expect(service.createDirectory('/tmp/gone', 'scifi')).rejects.toBeInstanceOf(BadRequestException); + await expect(service.createDirectory('/tmp/books/gone', 'scifi')).rejects.toBeInstanceOf(BadRequestException); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('rechecks browse-root containment before accessing the filesystem', async () => { + pathPolicy.getBrowseRoot.mockReturnValue('/books'); + pathPolicy.resolveBrowsePath.mockResolvedValue('/etc'); + + await expect(service.createDirectory('/books/../../etc', 'scifi')).rejects.toBeInstanceOf(ForbiddenException); + expect(lstatMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); }); }); diff --git a/server/src/modules/path/path.service.ts b/server/src/modules/path/path.service.ts index 0e619b702..20d6ad526 100644 --- a/server/src/modules/path/path.service.ts +++ b/server/src/modules/path/path.service.ts @@ -9,7 +9,7 @@ import { Logger, } from '@nestjs/common'; import { lstat, mkdir, readdir } from 'fs/promises'; -import { join, resolve, sep } from 'path'; +import { basename, isAbsolute, join, relative, resolve, sep } from 'path'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { PathPolicyService } from './path-policy.service'; @@ -27,26 +27,25 @@ export class PathService { } async listDirectories(rawPath?: string): Promise { - const resolved = await this.pathPolicy.resolveBrowsePath(rawPath); + const browseRoot = resolve(this.pathPolicy.getBrowseRoot()); + const resolved = resolve(await this.pathPolicy.resolveBrowsePath(rawPath)); + const relativePath = relative(browseRoot, resolved); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return []; + if (this.isBlocked(resolved)) { return []; } try { - const rootStat = await lstat(resolved); // codeql[js/path-injection] + const rootStat = await lstat(resolved); if (rootStat.isSymbolicLink()) return []; - const entries = await readdir(resolved, { withFileTypes: true }); // codeql[js/path-injection] + const entries = await readdir(resolved, { withFileTypes: true }); const dirs: DirectoryEntry[] = []; for (const entry of entries) { - if (!entry.isDirectory()) continue; + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; if (entry.name.startsWith('.')) continue; const full = join(resolved, entry.name); - try { - const s = await lstat(full); // codeql[js/path-injection] - if (s.isDirectory() && !s.isSymbolicLink()) dirs.push({ name: entry.name, path: full }); - } catch { - // skip inaccessible entries - } + dirs.push({ name: entry.name, path: full }); } return dirs.sort((a, b) => a.name.localeCompare(b.name)); } catch { @@ -56,7 +55,13 @@ export class PathService { async createDirectory(rawParent: string, rawName: string): Promise { const name = this.assertSafeName(rawName); - const parent = await this.pathPolicy.resolveBrowsePath(rawParent); + const browseRoot = resolve(this.pathPolicy.getBrowseRoot()); + const parent = resolve(await this.pathPolicy.resolveBrowsePath(rawParent)); + const relativeParent = relative(browseRoot, parent); + if (relativeParent === '..' || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) { + throw new ForbiddenException('Cannot create a folder outside the configured library browse root'); + } + if (this.isBlocked(parent)) { throw new ForbiddenException('Cannot create a folder in this location'); } @@ -64,21 +69,23 @@ export class PathService { const startedAt = Date.now(); this.logger.log(`[path.create_folder] [start] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" - create folder started`); try { - const parentStat = await lstat(parent); // codeql[js/path-injection] + const parentStat = await lstat(parent); if (parentStat.isSymbolicLink()) throw new ForbiddenException('Cannot create a folder under a symbolic link'); if (!parentStat.isDirectory()) throw new BadRequestException('Parent path is not a directory'); const target = join(parent, name); - const prefix = parent.endsWith(sep) ? parent : parent + sep; const resolvedTarget = resolve(target); - if (!resolvedTarget.startsWith(prefix)) { + const relativeTarget = relative(parent, resolvedTarget); + if (relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { throw new BadRequestException('Invalid folder name'); } await this.pathPolicy.assertWithinBrowseRoot(resolvedTarget); - await mkdir(target); // codeql[js/path-injection] - this.logger.log(`[path.create_folder] [end] path="${sanitizeLogValue(target)}" durationMs=${Date.now() - startedAt} - create folder completed`); - return { name, path: target }; + await mkdir(resolvedTarget); + this.logger.log( + `[path.create_folder] [end] path="${sanitizeLogValue(resolvedTarget)}" durationMs=${Date.now() - startedAt} - create folder completed`, + ); + return { name, path: resolvedTarget }; } catch (error) { this.logger.warn( `[path.create_folder] [fail] parentPath="${sanitizeLogValue(parent)}" name="${sanitizeLogValue(name)}" durationMs=${Date.now() - startedAt} errorClass=${getErrorClass(error)} error="${sanitizeLogValue(getErrorMessage(error))}" - create folder failed`, @@ -93,15 +100,16 @@ export class PathService { private assertSafeName(rawName: string): string { const name = (rawName ?? '').trim(); + const safeName = basename(name); if (!name) throw new BadRequestException('Folder name is required'); - if (name.includes('/') || name.includes('\\') || name.includes(sep)) { + if (safeName !== name || safeName.includes('/') || safeName.includes('\\') || safeName.includes(sep)) { throw new BadRequestException('Folder name cannot contain path separators'); } - if (name === '.' || name === '..' || name.startsWith('.')) { + if (safeName === '.' || safeName === '..' || safeName.startsWith('.')) { throw new BadRequestException('Folder name cannot start with a dot'); } - if (name.includes('\0')) throw new BadRequestException('Folder name contains invalid characters'); - return name; + if (safeName.includes('\0')) throw new BadRequestException('Folder name contains invalid characters'); + return safeName; } private mapCreateError(error: unknown): HttpException { diff --git a/server/src/modules/position-converter/kobo-span.core.test.ts b/server/src/modules/position-converter/kobo-span.core.test.ts index f797a9d40..49882471a 100644 --- a/server/src/modules/position-converter/kobo-span.core.test.ts +++ b/server/src/modules/position-converter/kobo-span.core.test.ts @@ -79,6 +79,10 @@ describe('parseSpanSelector', () => { it('round-trips through spanSelectorFromId', () => { expect(parseSpanSelector(spanSelectorFromId('kobo.12.34'))).toBe('kobo.12.34'); }); + + it('escapes backslashes before building a selector', () => { + expect(spanSelectorFromId('kobo\\.12.34')).toBe('span#kobo\\\\\\.12\\.34'); + }); }); describe('buildKoboSpanIndex', () => { diff --git a/server/src/modules/position-converter/kobo-span.core.ts b/server/src/modules/position-converter/kobo-span.core.ts index 605d7ed04..56aaf6517 100644 --- a/server/src/modules/position-converter/kobo-span.core.ts +++ b/server/src/modules/position-converter/kobo-span.core.ts @@ -64,7 +64,7 @@ export function parseSpanSelector(path: string): string | null { } export function spanSelectorFromId(id: string): string { - return `span#${id.replace(/\./g, '\\.')}`; + return `span#${id.replace(/\\/g, '\\\\').replace(/\./g, '\\.')}`; } function isKoboSpanElement(node: CfiNode): boolean { diff --git a/server/src/modules/release-notes/highlights.lint.test.ts b/server/src/modules/release-notes/highlights.lint.test.ts index d27e8317a..e402daafa 100644 --- a/server/src/modules/release-notes/highlights.lint.test.ts +++ b/server/src/modules/release-notes/highlights.lint.test.ts @@ -35,7 +35,13 @@ describe('lintHighlights', () => { it('warns when an explicit image uses a non-allowlisted host', () => { const body = ['## Highlights', '- **Thing** - body.', '![](https://imgur.com/x.png)'].join('\n'); const r = lintHighlights(body); - expect(r.warnings.some((w) => w.includes('dropped') && w.includes('imgur.com'))).toBe(true); + expect( + r.warnings.some( + (w) => + w === + 'Media will be dropped (host not allowed): https://imgur.com/x.png. Use a GitHub user-attachments or githubusercontent URL (drag-drop into the release).', + ), + ).toBe(true); }); it('does not warn for an allowlisted image', () => { diff --git a/server/src/modules/release-notes/highlights.parser.test.ts b/server/src/modules/release-notes/highlights.parser.test.ts index 3dafe4e42..0bcc970df 100644 --- a/server/src/modules/release-notes/highlights.parser.test.ts +++ b/server/src/modules/release-notes/highlights.parser.test.ts @@ -159,6 +159,13 @@ describe('parseHighlights', () => { expect(parseHighlights(body)).toEqual([]); }); + it('removes malformed nested tag-like text without creating executable markup', () => { + const result = parseHighlights('## Highlights\n- **Safe** - before ipt>alert(1)ipt> after')[0]; + + expect(result.title).toBe('Safe'); + expect(result.body).not.toContain(' { const body = ['## Highlights', '- **One** - first.', '', '---', '', '- **Two** - second.', '', '### Features', '- not a highlight'].join('\n'); expect(parseHighlights(body).map((h) => h.title)).toEqual(['One', 'Two']); diff --git a/server/src/modules/release-notes/highlights.parser.ts b/server/src/modules/release-notes/highlights.parser.ts index 6167fc976..6512a6d17 100644 --- a/server/src/modules/release-notes/highlights.parser.ts +++ b/server/src/modules/release-notes/highlights.parser.ts @@ -1,5 +1,6 @@ import type { ReleaseHighlight, ReleaseMedia } from '@bookorbit/types'; +import { htmlToPlainText } from '../../common/utils/html-to-text.utils'; import { MAX_HIGHLIGHTS_PER_RELEASE, MAX_MEDIA_PER_HIGHLIGHT } from './release-notes.constants'; const HEADING_RE = /^#{1,6}\s+/; @@ -18,14 +19,29 @@ const MEDIA_LINK_RE = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; const IMAGE_MD_RE = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; // GitHub's web editor inserts dropped images as `` (and video sources similarly). const HTML_MEDIA_SRC_RE = /<(?:img|video|source)\b[^>]*?\bsrc=["']([^"']+)["']/gi; -const HTML_TAG_RE = /<[^>]+>/g; const BARE_URL_RE = /https?:\/\/[^\s)"'<>\]]+/g; const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|ogv|ogg)(?:[?#].*)?$/i; const LEADING_SEP_RE = /^\s*[-:–—]\s*/; /** Strip HTML comments but preserve standalone `` markers so the parser can read the icon. */ function stripHtmlComments(text: string): string { - return text.replace(//g, (m) => (ICON_COMMENT_ONLY_RE.test(m.trim()) ? m : '')); + let result = ''; + let cursor = 0; + + while (cursor < text.length) { + const start = text.indexOf('', start + 4); + if (end < 0) return result; + + const comment = text.slice(start, end + 3); + if (ICON_COMMENT_ONLY_RE.test(comment.trim())) result += comment; + cursor = end + 3; + } + + return result; } function isHighlightsHeading(line: string): boolean { @@ -130,7 +146,7 @@ export function parseHighlightEntries(body: string | null | undefined): ParsedHi const iconComment = combined.match(ICON_COMMENT_RE); if (iconComment) icon = iconComment[1]; - let text = entry.text.replace(HTML_TAG_RE, '').replace(MEDIA_LINK_RE, '').replace(BARE_URL_RE, '').trim(); + let text = htmlToPlainText(entry.text).replace(MEDIA_LINK_RE, '').replace(BARE_URL_RE, '').trim(); const tokenMatch = text.match(ICON_TOKEN_RE); if (tokenMatch) { diff --git a/server/src/modules/release-notes/release-notes.service.test.ts b/server/src/modules/release-notes/release-notes.service.test.ts index 40fa1e58a..399c88b0d 100644 --- a/server/src/modules/release-notes/release-notes.service.test.ts +++ b/server/src/modules/release-notes/release-notes.service.test.ts @@ -88,7 +88,7 @@ describe('ReleaseNotesService.getSince', () => { } as unknown as ConfigService; const appSettings = { isUpdateCheckEnabled: vi.fn().mockResolvedValue(true) } as unknown as AppSettingsService; const fetchMock = vi.fn((url: string) => - url.includes('api.github.com') + new URL(url).hostname === 'api.github.com' ? Promise.resolve({ ok: true, json: () => Promise.resolve(releases) }) : Promise.resolve({ headers: { get: () => 'video/mp4' } }), ); @@ -97,7 +97,7 @@ describe('ReleaseNotesService.getSince', () => { const highlights = (await service.getSince('v1.0.0')).releases[0].highlights; expect(highlights[0].media).toEqual([{ url: sharedUrl, type: 'video' }]); expect(highlights[1].media).toEqual([{ url: sharedUrl, type: 'video' }]); - const probeCalls = fetchMock.mock.calls.filter(([u]) => !String(u).includes('api.github.com')); + const probeCalls = fetchMock.mock.calls.filter(([u]) => new URL(String(u)).hostname !== 'api.github.com'); expect(probeCalls).toHaveLength(1); }); diff --git a/server/src/modules/upload/pattern-resolver.test.ts b/server/src/modules/upload/pattern-resolver.test.ts index ada179b77..f41de2828 100644 --- a/server/src/modules/upload/pattern-resolver.test.ts +++ b/server/src/modules/upload/pattern-resolver.test.ts @@ -8,6 +8,7 @@ import { replacePlaceholders, resolveDownloadFilename, resolveUploadPath, + sanitizePathSegment, validatePattern, } from '@bookorbit/types'; @@ -33,6 +34,12 @@ const DECIMAL_INDEX: Record = { seriesIndex: '01.5', }; +describe('sanitizePathSegment', () => { + it('removes a large trailing run of dots and spaces in linear time', () => { + expect(sanitizePathSegment(`book${'. '.repeat(100_000)}`)).toBe('book'); + }); +}); + // ── applyModifier ───────────────────────────────────────────────────────────── describe('applyModifier', () => {