diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 14b64ccb..b8aa365e 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -41,6 +41,8 @@ In-scope components include: - release packaging and update metadata - archive extraction, durable storage, and process-execution boundaries - privacy sanitization and browser-side injection defenses +- nonce, replay, transaction-idempotency, and mutation rate-limit controls +- installed runtime integrity and privacy-safe security audit verification ## Package Verification @@ -49,6 +51,11 @@ GitHub releases also publish GitHub artifact attestations for build provenance a the CycloneDX SBOM. Verification steps and the trust model are documented in [`docs/security/PACKAGE_TRUST.md`](../docs/security/PACKAGE_TRUST.md). +The runtime request model is documented in +[`docs/security/REQUEST_SECURITY.md`](../docs/security/REQUEST_SECURITY.md), and +post-install verification is documented in +[`docs/security/RUNTIME_INTEGRITY.md`](../docs/security/RUNTIME_INTEGRITY.md). + Out-of-scope: - Unraid core vulnerabilities diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1b107e9a..56f16d26 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,13 +32,13 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Initialize CodeQL - uses: github/codeql-action/init@3b0bd1d116c0bde30213346b22d4f634d96a2fb0 # v3 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: languages: javascript queries: security-extended,security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@3b0bd1d116c0bde30213346b22d4f634d96a2fb0 # v3 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3b0bd1d116c0bde30213346b22d4f634d96a2fb0 # v3 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 00000000..46abdb74 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,33 @@ +name: Dependency Review + +on: + pull_request: + branches: + - dev + - main + +permissions: + contents: read + +concurrency: + group: folderview-plus-dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5 + with: + fail-on-severity: high + license-check: true + allow-licenses: Apache-2.0, BSD-3-Clause, BlueOak-1.0.0, ISC, MIT, MPL-2.0 + retry-on-snapshot-warnings: true + warn-only: false diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..afbd8e19 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,52 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '29 7 * * 2' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: folderview-plus-scorecard + cancel-in-progress: false + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + id-token: write + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload Scorecard results to code scanning + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + sarif_file: results.sarif + + - name: Retain Scorecard report + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: openssf-scorecard + path: results.sarif + if-no-files-found: error + retention-days: 5 diff --git a/archive/folderview.plus-2026.07.23.13.txz b/archive/folderview.plus-2026.07.23.13.txz deleted file mode 100644 index 8b711bab..00000000 Binary files a/archive/folderview.plus-2026.07.23.13.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.07.23.13.txz.sha256 b/archive/folderview.plus-2026.07.23.13.txz.sha256 deleted file mode 100644 index 6fd4a253..00000000 --- a/archive/folderview.plus-2026.07.23.13.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -1f7feab5d7cdb83ebdde1e1dfdd502d040fef4fe735849d6c3ff728e3ca1031e folderview.plus-2026.07.23.13.txz diff --git a/archive/folderview.plus-2026.07.28.05.txz b/archive/folderview.plus-2026.07.28.05.txz new file mode 100644 index 00000000..58c6abe0 Binary files /dev/null and b/archive/folderview.plus-2026.07.28.05.txz differ diff --git a/archive/folderview.plus-2026.07.28.05.txz.sha256 b/archive/folderview.plus-2026.07.28.05.txz.sha256 new file mode 100644 index 00000000..ab90fa29 --- /dev/null +++ b/archive/folderview.plus-2026.07.28.05.txz.sha256 @@ -0,0 +1 @@ +91a62bc1615c939ce7deba43247a095594c6bc7b2f09e102c11d1f779616eaf4 folderview.plus-2026.07.28.05.txz diff --git a/docs/codeql-triage.md b/docs/codeql-triage.md index eff19fa3..5dcd45de 100644 --- a/docs/codeql-triage.md +++ b/docs/codeql-triage.md @@ -23,7 +23,14 @@ The baseline predates the changes on `dev`; it is retained here so alert reducti ## Enforcement -CodeQL runs for pushes and pull requests targeting both `dev` and `main`, plus the weekly scheduled scan. Repository rules require CodeQL to report no new high-or-higher security findings before a protected branch can be merged. Code-quality findings remain part of CI and triage without being mislabeled as security vulnerabilities. +CodeQL Action v4 runs for pushes and pull requests targeting both `dev` and +`main`, plus the weekly scheduled scan. Repository rules require CodeQL to report +no new high-or-higher security findings before a protected branch can be merged. +Dependency Review separately rejects pull requests that introduce high-or-critical +known vulnerabilities or licenses outside the repository's approved quality-tooling +set. OpenSSF Scorecard publishes a scheduled supply-chain posture report to code +scanning. Code-quality findings remain part of CI and triage without being +mislabeled as security vulnerabilities. ## Review Procedure diff --git a/docs/releases/2026.07.28.05.md b/docs/releases/2026.07.28.05.md new file mode 100644 index 00000000..fecb707a --- /dev/null +++ b/docs/releases/2026.07.28.05.md @@ -0,0 +1,48 @@ +# FolderView Plus 2026.07.28.05 + +This security update adds continuous runtime assurance, replay-resistant +mutations, auditable security events, and stronger repository supply-chain +checks without exporting sensitive request or system data. + +## Request and mutation security + +- Security: Require short-lived, target-bound, one-time nonces for shared API + requests and reject expired, mismatched, or reused mutation authorization. +- Security: Reject replayed mutation transaction identifiers, apply bounded + endpoint-specific rate limits, and return standards-based retry guidance. +- Security: Remove the legacy GET backup-download path and route downloads + through the guarded POST request client. +- Security: Add a dedicated nonce bootstrap endpoint with explicit API-contract + metadata and fail-closed endpoint, action, content-type, and request-size + enforcement. + +## Runtime integrity and diagnostics + +- Security: Generate a deterministic SHA-256 runtime integrity manifest inside + every plugin package and verify expected files, permissions, and unexpected + executable content at runtime. +- Security: Record a bounded HMAC hash-chained audit trail for security decisions + using only sanitized action metadata, random trace identifiers, status, and + timestamps. +- Diagnostics: Include sanitized runtime-integrity and security-audit health in + diagnostics and support bundles without exposing payloads, names, paths, + addresses, tokens, nonces, or other private values. +- Security: Verify packaged runtime-manifest coverage and hashes during release + validation and installation smoke testing. + +## Repository and supply-chain assurance + +- Security: Upgrade CodeQL to the immutable v4 action and add a high-severity + dependency-review merge gate with an explicit license policy. +- Security: Add a scheduled OpenSSF Scorecard workflow that publishes SARIF + findings through GitHub code scanning with least-privilege permissions. +- Security: Require Dependency Review in managed branch-protection policy and + expand the CycloneDX SBOM to cover the complete shipped runtime and workflow + surface. +- Docs: Document mutation authorization, replay protection, rate limiting, + runtime integrity, audit privacy, package verification, and security-response + expectations. +- Test: Add functional regression coverage for nonce consumption, target + binding, replay rejection, rate limits, path confinement, audit-chain tamper + detection, runtime-manifest verification, workflow policy, and package + integrity. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 25a72148..e6e17a98 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -6,7 +6,7 @@ "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.07.28.04", + "version": "2026.07.28.05", "properties": [ { "name": "folderview-plus:runtime-dependencies", @@ -63,6 +63,19 @@ } ] }, + { + "type": "application", + "name": "actions/dependency-review-action", + "version": "a1d282b36b6f3519aa1f3fc636f609c47dddb294", + "scope": "optional", + "purl": "pkg:github/actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294", + "properties": [ + { + "name": "folderview-plus:usage", + "value": "build-only-github-action" + } + ] + }, { "type": "application", "name": "actions/setup-node", @@ -89,6 +102,19 @@ } ] }, + { + "type": "application", + "name": "ossf/scorecard-action", + "version": "2d1146689b8cda280b9bc96326124645441f03bc", + "scope": "optional", + "purl": "pkg:github/ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc", + "properties": [ + { + "name": "folderview-plus:usage", + "value": "build-only-github-action" + } + ] + }, { "type": "library", "name": "@bcoe/v8-coverage", diff --git a/docs/security/PACKAGE_TRUST.md b/docs/security/PACKAGE_TRUST.md index f8dbae0e..524b833e 100644 --- a/docs/security/PACKAGE_TRUST.md +++ b/docs/security/PACKAGE_TRUST.md @@ -12,6 +12,10 @@ compatible while stronger supply-chain evidence is available to administrators. sidecar, archive filename, package version, and archive bytes to agree exactly. - The package contains `build-metadata.json`, which records the channel, version, source-content digest, archive URL, and icon-pack identity. +- The package contains `runtime-integrity.json`, which records SHA-256 and + expected modes for installed PHP, JavaScript, page, CSS, helper, and critical + metadata files. Diagnostics compares the installed tree with this manifest + without automatically modifying the system. - Icon-pack installation verifies SHA-256 before a bounded archive preflight, extracts into private staging, rejects links/special files/traversal, and atomically activates the verified tree. @@ -39,3 +43,6 @@ gh attestation verify folderview.plus-YYYY.MM.DD.UU.txz \ Unraid installation does not require the GitHub CLI and continues to use manifest SHA-256 verification. Attestations are an additional origin/build claim for administrators and release auditing, not a replacement for the on-system checksum. + +Installed-runtime verification and recovery guidance is documented in +[`RUNTIME_INTEGRITY.md`](RUNTIME_INTEGRITY.md). diff --git a/docs/security/REQUEST_SECURITY.md b/docs/security/REQUEST_SECURITY.md new file mode 100644 index 00000000..b9886ee0 --- /dev/null +++ b/docs/security/REQUEST_SECURITY.md @@ -0,0 +1,61 @@ +# Request security and abuse controls + +FolderView Plus layers its own request controls over the authenticated Unraid +webGUI session. These controls do not replace Unraid authentication. + +## Mutation authorization + +Every plugin-owned mutation must: + +1. use `POST`; +2. carry the FolderView Plus request marker; +3. present the per-install request token; +4. pass same-origin `Origin` and `Referer` checks when those headers exist; +5. obtain a short-lived nonce from `security.php`; +6. bind that nonce to the target PHP endpoint and action; +7. consume the nonce exactly once; +8. present a unique transaction ID; and +9. remain within the API manifest's rate limit. + +The browser request client performs nonce acquisition automatically. Mutation +retries remain disabled, so an uncertain response is reconciled from current +server state instead of replaying an operation. + +The unload telemetry action is the only normal replay-protection exception. It +uses `sendBeacon`, cannot synchronously request a nonce, remains protected by the +install token and same-origin checks, and has a bounded high-volume telemetry +rate limit. Every exception must include `replayExemptReason` in +`server/api-endpoints.json`; the API contract guard rejects undocumented +exceptions. + +## Rate limiting + +The API manifest provides a generous default mutation budget. Restore, +delete-all, recovery, repair, and similar expensive operations use tighter +action-specific budgets. A rejected request returns HTTP 429 and `Retry-After`. +Read-only hydration is not rate limited by these mutation controls. + +Ephemeral nonce, transaction, and rate state lives under +`/var/run/folderview.plus/` with private permissions. It intentionally resets on +reboot and does not write high-frequency request state to the Unraid boot device. + +## Security audit chain + +Authorized mutations append a bounded security event containing only: + +- declared endpoint, action, and audit category; +- authorization result; +- whether replay protection applied; +- random trace and transaction IDs; and +- timestamp and chain metadata. + +Names, paths, addresses, URLs, payloads, request tokens, and nonces are never +stored in this ledger. Events are HMAC chained with the protected install token. +Diagnostics reports whether the retained chain verifies but does not +automatically repair or discard a failed chain. + +## Compatibility + +The former unauthenticated backup-download GET route has been removed. Maintained +UI downloads use the shared request client, guarded POST, a one-time nonce, and a +Blob response. diff --git a/docs/security/RUNTIME_INTEGRITY.md b/docs/security/RUNTIME_INTEGRITY.md new file mode 100644 index 00000000..e58ee115 --- /dev/null +++ b/docs/security/RUNTIME_INTEGRITY.md @@ -0,0 +1,31 @@ +# Installed runtime integrity + +Every FolderView Plus package contains `runtime-integrity.json`. The manifest is +generated after channel-specific package transformations and before archive +creation. + +The manifest records SHA-256, byte size, and expected installed mode for: + +- plugin page entrypoints; +- PHP endpoints and shared server libraries; +- browser runtime JavaScript; +- packaged shell/PHP helper scripts; +- plugin CSS; and +- critical runtime JSON metadata. + +The manifest excludes itself and large passive asset collections. Package SHA-256 +and GitHub attestations establish archive origin; the runtime manifest detects +changes after extraction. + +Diagnostics compares the installed files with this manifest and reports: + +- missing files; +- modified content; +- unexpected executable/runtime files; +- unreadable files; and +- changed permission modes. + +Sanitized diagnostics hash finding paths. Full diagnostics can show the packaged +relative path. Integrity checking is detection-only: FolderView Plus never +silently restores, deletes, or replaces a finding. Reinstalling the same verified +package is the supported recovery action after the cause has been reviewed. diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index 941b01ff..1f181eaa 100644 --- a/docs/security/csp-readiness.json +++ b/docs/security/csp-readiness.json @@ -3,7 +3,7 @@ "mode": "report-only-shared-unraid-document", "scope": { "scannedRoot": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus", - "scannedFiles": 149, + "scannedFiles": 151, "exclusions": [ "third-party icon asset pack", "localization catalogs", diff --git a/folderview.plus.plg b/folderview.plus.plg index de246681..bdc07d94 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,6 +18,50 @@ +###2026.07.28.05 +# FolderView Plus 2026.07.28.05 +This security update adds continuous runtime assurance, replay-resistant +mutations, auditable security events, and stronger repository supply-chain +checks without exporting sensitive request or system data. +## Request and mutation security +- Security: Require short-lived, target-bound, one-time nonces for shared API + requests and reject expired, mismatched, or reused mutation authorization. +- Security: Reject replayed mutation transaction identifiers, apply bounded + endpoint-specific rate limits, and return standards-based retry guidance. +- Security: Remove the legacy GET backup-download path and route downloads + through the guarded POST request client. +- Security: Add a dedicated nonce bootstrap endpoint with explicit API-contract + metadata and fail-closed endpoint, action, content-type, and request-size + enforcement. +## Runtime integrity and diagnostics +- Security: Generate a deterministic SHA-256 runtime integrity manifest inside + every plugin package and verify expected files, permissions, and unexpected + executable content at runtime. +- Security: Record a bounded HMAC hash-chained audit trail for security decisions + using only sanitized action metadata, random trace identifiers, status, and + timestamps. +- Diagnostics: Include sanitized runtime-integrity and security-audit health in + diagnostics and support bundles without exposing payloads, names, paths, + addresses, tokens, nonces, or other private values. +- Security: Verify packaged runtime-manifest coverage and hashes during release + validation and installation smoke testing. +## Repository and supply-chain assurance +- Security: Upgrade CodeQL to the immutable v4 action and add a high-severity + dependency-review merge gate with an explicit license policy. +- Security: Add a scheduled OpenSSF Scorecard workflow that publishes SARIF + findings through GitHub code scanning with least-privilege permissions. +- Security: Require Dependency Review in managed branch-protection policy and + expand the CycloneDX SBOM to cover the complete shipped runtime and workflow + surface. +- Docs: Document mutation authorization, replay protection, rate limiting, + runtime integrity, audit privacy, package verification, and security-response + expectations. +- Test: Add functional regression coverage for nonce consumption, target + binding, replay rejection, rate limits, path confinement, audit-chain tamper + detection, runtime-manifest verification, workflow policy, and package + integrity. + + ###2026.07.28.04 # FolderView Plus 2026.07.28.04 This security update strengthens archive handling, operating-system boundaries, diff --git a/pkg_build.sh b/pkg_build.sh index 3c489fc9..89140ce6 100644 --- a/pkg_build.sh +++ b/pkg_build.sh @@ -12,6 +12,7 @@ release_guard_script="$CWD/scripts/release_guard.sh" install_smoke_script="$CWD/scripts/install_smoke.sh" icon_asset_pack_guard_script="$CWD/scripts/icon_asset_pack_guard.sh" ensure_changes_entry_script="$CWD/scripts/ensure_plg_changes_entry.sh" +runtime_integrity_generator="$CWD/scripts/generate_runtime_integrity_manifest.mjs" changes_entry_timeout_raw="${FVPLUS_CHANGES_ENTRY_TIMEOUT_SEC:-10}" archive_prefix="folderview.plus" archive_dir="$CWD/archive" @@ -482,6 +483,10 @@ if [ ! -f "$ensure_changes_entry_script" ]; then echo "ERROR: Missing CHANGES helper script: $ensure_changes_entry_script" >&2 exit 1 fi +if [ ! -f "$runtime_integrity_generator" ]; then + echo "ERROR: Missing runtime integrity generator: $runtime_integrity_generator" >&2 + exit 1 +fi if [ ! -f "$icon_asset_pack_guard_script" ]; then echo "ERROR: Missing icon asset-pack guard script: $icon_asset_pack_guard_script" >&2 exit 1 @@ -636,6 +641,12 @@ cat > "$build_metadata_path" <&2 diff --git a/scripts/api_contract_guard.mjs b/scripts/api_contract_guard.mjs index ec941ba5..e713a93b 100644 --- a/scripts/api_contract_guard.mjs +++ b/scripts/api_contract_guard.mjs @@ -12,7 +12,7 @@ const serverDir = serverDirArgIndex >= 0 && process.argv[serverDirArgIndex + 1] const manifestPath = path.join(serverDir, 'api-endpoints.json'); const allowedMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); const allowedAccess = new Set(['read-only', 'mutation', 'mixed']); -const allowedTokens = new Set(['none', 'mutation']); +const allowedTokens = new Set(['none', 'mutation', 'bootstrap']); const allowedResponses = new Set(['json', 'text', 'binary', 'mixed']); function fail(message) { @@ -51,12 +51,25 @@ function validateContract(contract, label, { allowMixed = false } = {}) { fail(`${label}.access must be ${allowMixed ? 'read-only, mutation, or mixed' : 'read-only or mutation'}.`); } if (!allowedTokens.has(contract.requestToken)) fail(`${label}.requestToken is invalid.`); - if (contract.access === 'mutation' && contract.requestToken !== 'mutation') { - fail(`${label} is mutating but does not require the mutation request guard.`); + if (contract.access === 'mutation' && !['mutation', 'bootstrap'].includes(contract.requestToken)) { + fail(`${label} is mutating but does not require an approved request guard.`); } if (contract.access === 'read-only' && contract.requestToken !== 'none') { fail(`${label} is read-only but declares a mutation token requirement.`); } + if (typeof contract.replayProtection !== 'boolean') { + fail(`${label}.replayProtection must be a boolean.`); + } + if (contract.access === 'mutation' && contract.replayProtection === false + && (typeof contract.replayExemptReason !== 'string' || contract.replayExemptReason.trim() === '')) { + fail(`${label} disables replay protection without documenting replayExemptReason.`); + } + const rateLimit = contract.rateLimit; + if (!rateLimit || typeof rateLimit !== 'object' + || !Number.isInteger(rateLimit.windowSeconds) || rateLimit.windowSeconds < 1 || rateLimit.windowSeconds > 3600 + || !Number.isInteger(rateLimit.maxRequests) || rateLimit.maxRequests < 1 || rateLimit.maxRequests > 10000) { + fail(`${label}.rateLimit must define bounded windowSeconds and maxRequests.`); + } validateStringList(contract.requestContentTypes, `${label}.requestContentTypes`); validateStringList(contract.responseContentTypes, `${label}.responseContentTypes`, { nonEmpty: true }); if (!Number.isInteger(contract.maxRequestBytes) || contract.maxRequestBytes < 0) { @@ -81,7 +94,7 @@ function validateContract(contract, label, { allowMixed = false } = {}) { try { const manifest = readJson(manifestPath); - if (manifest.schemaVersion !== 1) fail('api-endpoints.json schemaVersion must be 1.'); + if (manifest.schemaVersion !== 2) fail('api-endpoints.json schemaVersion must be 2.'); if (!manifest.defaults || typeof manifest.defaults !== 'object') fail('api-endpoints.json must define defaults.'); if (!manifest.endpoints || typeof manifest.endpoints !== 'object' || Array.isArray(manifest.endpoints)) { fail('api-endpoints.json must define an endpoints object.'); @@ -142,6 +155,9 @@ try { if (!/requestToken[^\n]*mutation[\s\S]*?requireMutationRequestGuard\(\)/.test(contractLib)) { fail('The API contract runtime must route mutation declarations through requireMutationRequestGuard().'); } + if (!/requestToken[^\n]*bootstrap[\s\S]*?fvplus_require_nonce_bootstrap_guard\(\)/.test(contractLib)) { + fail('The API contract runtime must route nonce bootstrap declarations through fvplus_require_nonce_bootstrap_guard().'); + } } catch (error) { fail(error.message); } diff --git a/scripts/apply_branch_protection.sh b/scripts/apply_branch_protection.sh index 1d6ea9eb..97d9c8d9 100644 --- a/scripts/apply_branch_protection.sh +++ b/scripts/apply_branch_protection.sh @@ -18,7 +18,8 @@ cat > "${TMP_DIR}/main.json" <<'EOF' "strict": true, "contexts": [ "quality", - "Analyze (JavaScript)" + "Analyze (JavaScript)", + "Dependency Review" ] }, "enforce_admins": false, @@ -44,7 +45,8 @@ cat > "${TMP_DIR}/dev.json" <<'EOF' "required_status_checks": { "strict": true, "contexts": [ - "quality" + "quality", + "Dependency Review" ] }, "enforce_admins": false, diff --git a/scripts/fixture_browser_tests.mjs b/scripts/fixture_browser_tests.mjs index ac89ee20..ea04422e 100644 --- a/scripts/fixture_browser_tests.mjs +++ b/scripts/fixture_browser_tests.mjs @@ -60,7 +60,21 @@ const readRequestBody = (request) => new Promise((resolve, reject) => { const fixtureServer = http.createServer(async (request, response) => { try { const requestUrl = new URL(request.url || '/', 'http://127.0.0.1'); - if (requestUrl.pathname === '/api/echo') { + if (requestUrl.pathname === '/plugins/folderview.plus/server/security.php') { + const rawBody = await readRequestBody(request); + const body = Object.fromEntries(new URLSearchParams(rawBody)); + assert.equal(request.method, 'POST'); + assert.equal(body.action, 'issue_nonce'); + assert.equal(body.endpoint, 'echo.php'); + response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify({ + ok: true, + nonce: 'a'.repeat(64) + })); + return; + } + + if (requestUrl.pathname === '/api/echo.php') { const rawBody = await readRequestBody(request); const body = Object.fromEntries(new URLSearchParams(rawBody)); response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); @@ -70,7 +84,8 @@ const fixtureServer = http.createServer(async (request, response) => { headers: { request: request.headers['x-fv-request'] || '', token: request.headers['x-fv-token'] || '', - trace: request.headers['x-fv-trace'] || '' + trace: request.headers['x-fv-trace'] || '', + nonce: request.headers['x-fv-nonce'] || '' } })); return; @@ -823,12 +838,14 @@ test('Docker Privacy toggle preserves its widget through optimistic, confirmed, test('Standard request client sends mutation markers, token, and trace ID', async ({ page }) => { await page.goto(`${baseUrl}/runtime`, { waitUntil: 'load' }); - const response = await page.evaluate(() => window.FolderViewPlusRequest.postJson('/api/echo', { hello: 'world' })); + const response = await page.evaluate(() => window.FolderViewPlusRequest.postJson('/api/echo.php', { hello: 'world' })); assert.equal(response.ok, true); assert.equal(response.body.hello, 'world'); assert.equal(response.body._fv_request, '1'); + assert.equal(response.body._fv_nonce, 'a'.repeat(64)); assert.equal(response.headers.request, '1'); assert.equal(response.headers.token, 'fixture-request-token-1234567890'); + assert.equal(response.headers.nonce, 'a'.repeat(64)); assert.match(response.headers.trace, /^fv-/); }); diff --git a/scripts/generate_runtime_integrity_manifest.mjs b/scripts/generate_runtime_integrity_manifest.mjs new file mode 100644 index 00000000..953475f2 --- /dev/null +++ b/scripts/generate_runtime_integrity_manifest.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const valueAfter = (flag) => { + const index = process.argv.indexOf(flag); + return index >= 0 ? String(process.argv[index + 1] || '').trim() : ''; +}; + +const root = path.resolve(valueAfter('--root')); +const output = path.resolve(valueAfter('--output')); +if (!valueAfter('--root') || !valueAfter('--output')) { + console.error('Usage: node scripts/generate_runtime_integrity_manifest.mjs --root --output '); + process.exit(1); +} +if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) { + console.error(`Runtime integrity root is unavailable: ${root}`); + process.exit(1); +} +if (path.dirname(output) !== root || path.basename(output) !== 'runtime-integrity.json') { + console.error('Runtime integrity output must be runtime-integrity.json in the plugin root.'); + process.exit(1); +} + +const normalize = (absolute) => path.relative(root, absolute).replaceAll('\\', '/'); +const shouldTrack = (relative) => { + if (!relative || relative === 'runtime-integrity.json') { + return false; + } + if (/^[^/]+\.(?:page|php|json)$/i.test(relative)) { + return true; + } + if (!/^(?:server|scripts|styles)\//.test(relative)) { + return false; + } + return /\.(?:php|js|sh|css|json)$/i.test(relative); +}; +const walk = (directory) => { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + files.push(...walk(absolute)); + } else if (entry.isFile()) { + files.push(absolute); + } + } + return files; +}; + +const files = walk(root) + .map((absolute) => ({ absolute, relative: normalize(absolute) })) + .filter(({ relative }) => shouldTrack(relative)) + .sort((left, right) => left.relative.localeCompare(right.relative)) + .map(({ absolute, relative }) => { + const contents = fs.readFileSync(absolute); + return { + path: relative, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + size: contents.length, + mode: '0755' + }; + }); + +const manifest = { + schemaVersion: 1, + algorithm: 'sha256', + policy: 'folderview-plus-runtime-v1', + files +}; +fs.writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o755 }); +console.log(`Runtime integrity manifest generated for ${files.length} files.`); diff --git a/scripts/install_smoke.sh b/scripts/install_smoke.sh index 19a09ff8..bda3fadf 100644 --- a/scripts/install_smoke.sh +++ b/scripts/install_smoke.sh @@ -45,6 +45,7 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page" "./usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page" "./usr/local/emhttp/plugins/folderview.plus/folderview.plus.Dashboard.page" + "./usr/local/emhttp/plugins/folderview.plus/runtime-integrity.json" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.dirty.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.runtime-parity.js" @@ -82,11 +83,13 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/server/lib.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.process.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.filesystem-security.php" + "./usr/local/emhttp/plugins/folderview.plus/server/lib.security.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php" "./usr/local/emhttp/plugins/folderview.plus/server/apply_folder_settings.php" "./usr/local/emhttp/plugins/folderview.plus/server/read.php" "./usr/local/emhttp/plugins/folderview.plus/server/read_info.php" "./usr/local/emhttp/plugins/folderview.plus/server/runtime_snapshot.php" + "./usr/local/emhttp/plugins/folderview.plus/server/security.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.runtime-snapshot.php" "./usr/local/emhttp/plugins/folderview.plus/server/create.php" "./usr/local/emhttp/plugins/folderview.plus/server/update.php" @@ -120,12 +123,38 @@ if [[ ! -d "${PLUGIN_DIR}" ]]; then exit 1 fi +"${NODE_BIN}" - "$(fvplus::path_for_command "${NODE_BIN}" "${PLUGIN_DIR}")" <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.argv[2]; +const manifestPath = path.join(root, 'runtime-integrity.json'); +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +if (manifest.schemaVersion !== 1 || manifest.algorithm !== 'sha256' || !Array.isArray(manifest.files)) { + throw new Error('Extracted runtime integrity manifest is invalid.'); +} +for (const entry of manifest.files) { + const absolute = path.resolve(root, entry.path); + const relative = path.relative(root, absolute); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Runtime integrity path escapes the plugin root: ${entry.path}`); + } + const contents = fs.readFileSync(absolute); + const digest = crypto.createHash('sha256').update(contents).digest('hex'); + if (digest !== entry.sha256 || contents.length !== entry.size) { + throw new Error(`Runtime integrity verification failed for ${entry.path}`); + } +} +console.log(`Runtime integrity smoke verification passed for ${manifest.files.length} files.`); +NODE + REQUIRED_FILES=( "Folder.page" "FolderViewPlus.page" "folderview.plus.Docker.page" "folderview.plus.VMs.page" "folderview.plus.Dashboard.page" + "runtime-integrity.json" "scripts/folderviewplus.js" "scripts/folderviewplus.dirty.js" "scripts/folderviewplus.runtime-parity.js" @@ -148,6 +177,7 @@ REQUIRED_FILES=( "scripts/folderviewplus.bulk-assignment.js" "scripts/folderviewplus.runtime-actions.js" "scripts/folderviewplus.runtime-snapshot.js" + "scripts/folderviewplus.request.js" "scripts/folderviewplus.wizard.js" "scripts/folderviewplus.import.js" "scripts/folderviewplus.csp-events.js" @@ -168,12 +198,14 @@ REQUIRED_FILES=( "server/lib.php" "server/lib.process.php" "server/lib.filesystem-security.php" + "server/lib.security.php" "server/lib.diagnostics.php" "server/apply_folder_settings.php" "server/read.php" "server/read_info.php" "server/runtime_snapshot.php" "server/lib.runtime-snapshot.php" + "server/security.php" "server/create.php" "server/update.php" "server/delete.php" diff --git a/scripts/release_guard.sh b/scripts/release_guard.sh index e06fb14a..f0bf97f7 100644 --- a/scripts/release_guard.sh +++ b/scripts/release_guard.sh @@ -119,7 +119,9 @@ fi EXPECTED_PLUGIN_BRANCH="${FVPLUS_EXPECT_PLUGIN_BRANCH:-}" if [[ -z "${EXPECTED_PLUGIN_BRANCH}" ]]; then - if [[ -n "${GITHUB_REF_NAME:-}" ]]; then + if [[ "${GITHUB_BASE_REF:-}" =~ ^(main|dev)$ ]]; then + EXPECTED_PLUGIN_BRANCH="${GITHUB_BASE_REF}" + elif [[ -n "${GITHUB_REF_NAME:-}" ]]; then EXPECTED_PLUGIN_BRANCH="${GITHUB_REF_NAME#refs/heads/}" elif command -v git >/dev/null 2>&1 && git -C "${ROOT_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then EXPECTED_PLUGIN_BRANCH="$(git -C "${ROOT_DIR}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" @@ -412,6 +414,7 @@ fi REQUIRED_ARCHIVE_PATHS=( "./install/slack-desc" "./usr/local/emhttp/plugins/folderview.plus/build-metadata.json" + "./usr/local/emhttp/plugins/folderview.plus/runtime-integrity.json" "./usr/local/emhttp/plugins/folderview.plus/scripts/archive_preflight.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/install_report.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js" @@ -452,9 +455,11 @@ REQUIRED_ARCHIVE_PATHS=( "./usr/local/emhttp/plugins/folderview.plus/server/lib.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.process.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.filesystem-security.php" + "./usr/local/emhttp/plugins/folderview.plus/server/lib.security.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.runtime-snapshot.php" "./usr/local/emhttp/plugins/folderview.plus/server/runtime_snapshot.php" + "./usr/local/emhttp/plugins/folderview.plus/server/security.php" "./usr/local/emhttp/plugins/folderview.plus/server/apply_folder_settings.php" "./usr/local/emhttp/plugins/folderview.plus/server/update_notes.php" ) @@ -490,6 +495,45 @@ for (const field of ['sourceCommitSha', 'headCommitSha', 'sourceTreeSha']) { } NODE +ARCHIVE_RUNTIME_INTEGRITY_PATH="./usr/local/emhttp/plugins/folderview.plus/runtime-integrity.json" +TMP_ARCHIVE_RUNTIME_INTEGRITY="$(mktemp)" +trap 'rm -f "${TMP_ARCHIVE_RUNTIME_INTEGRITY}"' EXIT +tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_RUNTIME_INTEGRITY_PATH}" > "${TMP_ARCHIVE_RUNTIME_INTEGRITY}" +NODE_RUNTIME_INTEGRITY_PATH="$(fvplus::path_for_command "${NODE_BIN}" "${TMP_ARCHIVE_RUNTIME_INTEGRITY}")" +"${NODE_BIN}" - "${NODE_RUNTIME_INTEGRITY_PATH}" <<'NODE' +const fs = require('node:fs'); +const manifest = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const fail = (message) => { + console.error(`ERROR: Packaged runtime integrity manifest ${message}`); + process.exit(1); +}; +if (manifest.schemaVersion !== 1 || manifest.algorithm !== 'sha256') fail('has an invalid schema or algorithm.'); +if (!Array.isArray(manifest.files) || manifest.files.length < 100 || manifest.files.length > 1000) { + fail('has an implausible file inventory.'); +} +const paths = new Set(); +for (const entry of manifest.files) { + if (!entry || typeof entry.path !== 'string' || !/^[^/].+/.test(entry.path) || entry.path.includes('..')) { + fail('contains an invalid relative path.'); + } + if (!/^[a-f0-9]{64}$/.test(String(entry.sha256 || '')) || !Number.isInteger(entry.size) || entry.size < 0) { + fail(`contains invalid metadata for ${entry.path || 'unknown'}.`); + } + if (entry.mode !== '0755' || paths.has(entry.path)) fail(`contains a duplicate or invalid mode for ${entry.path}.`); + paths.add(entry.path); +} +for (const required of [ + 'server/lib.php', + 'server/lib.security.php', + 'server/security.php', + 'scripts/folderviewplus.request.js' +]) { + if (!paths.has(required)) fail(`does not cover ${required}.`); +} +NODE +rm -f "${TMP_ARCHIVE_RUNTIME_INTEGRITY}" +trap - EXIT + if ! grep -q 'click\.fvsectionheader' "${SOURCE_SETTINGS_JS}"; then echo "ERROR: Source folderviewplus.js is missing mobile section-toggle header binding." >&2 exit 1 diff --git a/scripts/workflow_self_check.sh b/scripts/workflow_self_check.sh index 4b7ba5f2..cb169ab6 100644 --- a/scripts/workflow_self_check.sh +++ b/scripts/workflow_self_check.sh @@ -30,6 +30,8 @@ for (const relativePath of [ '.github/workflows/backmerge-main-to-dev.yml', '.github/workflows/release-on-main.yml', '.github/workflows/codeql.yml', + '.github/workflows/dependency-review.yml', + '.github/workflows/scorecard.yml', '.github/workflows/scheduled-validation.yml', '.github/workflows/unraid-docker-upstream-monitor.yml', '.github/actions/setup-ci-env/action.yml', @@ -56,6 +58,8 @@ const ciWorkflow = read('.github/workflows/ci.yml'); const releaseOnMainWorkflow = read('.github/workflows/release-on-main.yml'); const backmergeWorkflow = read('.github/workflows/backmerge-main-to-dev.yml'); const codeqlWorkflow = read('.github/workflows/codeql.yml'); +const dependencyReviewWorkflow = read('.github/workflows/dependency-review.yml'); +const scorecardWorkflow = read('.github/workflows/scorecard.yml'); const scheduledValidationWorkflow = read('.github/workflows/scheduled-validation.yml'); const upstreamMonitorWorkflow = read('.github/workflows/unraid-docker-upstream-monitor.yml'); const jobBlock = (workflow, jobName) => { @@ -157,6 +161,22 @@ if (!/bash scripts\/build_release_notes\.sh/.test(releaseOnMainWorkflow)) { if (!/permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/.test(releaseOnMainWorkflow)) { fail('Release On Main must grant only the release, OIDC, and attestation permissions required for signed provenance.'); } +if ((codeqlWorkflow.match(/github\/codeql-action\/(?:init|autobuild|analyze)@[0-9a-f]{40}\s+# v4/g) || []).length !== 3) { + fail('CodeQL must use commit-pinned v4 init, autobuild, and analyze actions.'); +} +if (!/actions\/dependency-review-action@[0-9a-f]{40}\s+# v5/.test(dependencyReviewWorkflow) + || !/fail-on-severity:\s*high/.test(dependencyReviewWorkflow) + || !/license-check:\s*true/.test(dependencyReviewWorkflow) + || !/warn-only:\s*false/.test(dependencyReviewWorkflow)) { + fail('Dependency Review must fail pull requests on high-severity vulnerabilities and enforce the approved license policy.'); +} +if (!/ossf\/scorecard-action@[0-9a-f]{40}\s+# v2\.4\.4/.test(scorecardWorkflow) + || !/github\/codeql-action\/upload-sarif@[0-9a-f]{40}\s+# v4/.test(scorecardWorkflow) + || !/publish_results:\s*true/.test(scorecardWorkflow) + || !/security-events:\s*write/.test(scorecardWorkflow) + || !/id-token:\s*write/.test(scorecardWorkflow)) { + fail('OpenSSF Scorecard must publish signed results to GitHub code scanning with pinned actions.'); +} if ((releaseOnMainWorkflow.match(/uses:\s*actions\/attest@[0-9a-f]{40}\s+# v4/g) || []).length !== 2 || !/Attest release archive provenance/.test(releaseOnMainWorkflow) || !/Attest release archive SBOM/.test(releaseOnMainWorkflow) || @@ -231,6 +251,8 @@ for (const [workflowName, workflow, jobNames] of [ ['release-on-main', releaseOnMainWorkflow, ['release']], ['backmerge-main-to-dev', backmergeWorkflow, ['backmerge']], ['codeql', codeqlWorkflow, ['analyze']], + ['dependency-review', dependencyReviewWorkflow, ['dependency-review']], + ['scorecard', scorecardWorkflow, ['analysis']], ['scheduled-validation', scheduledValidationWorkflow, ['configuration', 'cross-browser-fixtures', 'live-unraid']], ['unraid-docker-upstream-monitor', upstreamMonitorWorkflow, ['monitor']] ]) { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js index 2b472005..30da4ea2 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js @@ -11105,7 +11105,7 @@ const restoreLatestBackup = (type) => { }); }; -const downloadBackupEntry = (type, name) => { +const downloadBackupEntry = async (type, name) => { let resolvedType; try { resolvedType = normalizeManagedType(type); @@ -11119,48 +11119,30 @@ const downloadBackupEntry = (type, name) => { return; } - const frameName = `fv-download-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const iframe = document.createElement('iframe'); - iframe.name = frameName; - iframe.style.display = 'none'; - document.body.appendChild(iframe); - - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/plugins/folderview.plus/server/backup.php'; - form.target = frameName; - form.style.display = 'none'; - - const addField = (fieldName, value) => { - const input = document.createElement('input'); - input.type = 'hidden'; - input.name = fieldName; - input.value = String(value ?? ''); - form.appendChild(input); - }; - - addField('action', 'download_post'); - addField('type', resolvedType); - addField('name', resolvedName); - addField('_fv_request', '1'); - const token = getOptionalRequestToken(); - if (token) { - addField('token', token); + if (!requestClient || typeof requestClient.postBlob !== 'function') { + showError('Download failed', new Error('The secured download client is unavailable. Refresh the page and try again.')); + return; + } + try { + const blob = await requestClient.postBlob('/plugins/folderview.plus/server/backup.php', { + action: 'download_post', + type: resolvedType, + name: resolvedName + }, { + timeoutMs: 30000 + }); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = resolvedName; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(objectUrl); + } catch (error) { + showError('Download failed', error); } - - document.body.appendChild(form); - form.submit(); - - window.setTimeout(() => { - if (form.parentNode) { - form.parentNode.removeChild(form); - } - }, 1000); - window.setTimeout(() => { - if (iframe.parentNode) { - iframe.parentNode.removeChild(iframe); - } - }, 20000); }; const deleteBackupEntry = (type, name) => { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.request.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.request.js index 2c8768b0..cbd07ca2 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.request.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.request.js @@ -10,6 +10,9 @@ const TRACE_PAYLOAD_KEY = '_fv_trace'; const TRANSACTION_HEADER_NAME = 'X-FV-Transaction'; const TRANSACTION_PAYLOAD_KEY = '_fv_transaction'; + const NONCE_HEADER_NAME = 'X-FV-Nonce'; + const NONCE_PAYLOAD_KEY = '_fv_nonce'; + const NONCE_ENDPOINT = '/plugins/folderview.plus/server/security.php'; const requestDiagnostics = []; let securityPrefilterConfigured = false; @@ -92,7 +95,13 @@ return ''; }; - const buildHeaders = (extraHeaders = {}, tokenStorageKey = DEFAULT_TOKEN_STORAGE_KEY, resolvedToken = '', traceId = '') => { + const buildHeaders = ( + extraHeaders = {}, + tokenStorageKey = DEFAULT_TOKEN_STORAGE_KEY, + resolvedToken = '', + traceId = '', + nonce = '' + ) => { const headers = { 'X-FV-Request': '1', ...(extraHeaders || {}) @@ -106,6 +115,10 @@ headers[TRACE_HEADER_NAME] = trace; headers[TRANSACTION_HEADER_NAME] = transactionIdForTrace(trace); } + const safeNonce = String(nonce || '').trim(); + if (safeNonce) { + headers[NONCE_HEADER_NAME] = safeNonce; + } return headers; }; @@ -115,13 +128,14 @@ && Object.prototype.toString.call(value) === '[object Object]' ); - const addMutationPayloadMarkers = (method, data, token, traceId = '') => { + const addMutationPayloadMarkers = (method, data, token, traceId = '', nonce = '') => { if (String(method || '').toUpperCase() !== 'POST') { return data; } const safeToken = String(token || '').trim(); const safeTraceId = String(traceId || '').trim(); const safeTransactionId = safeTraceId ? transactionIdForTrace(safeTraceId) : ''; + const safeNonce = String(nonce || '').trim(); if (typeof FormData !== 'undefined' && data instanceof FormData) { if (!data.has('_fv_request')) { data.append('_fv_request', '1'); @@ -135,6 +149,9 @@ if (safeTransactionId && !data.has(TRANSACTION_PAYLOAD_KEY)) { data.append(TRANSACTION_PAYLOAD_KEY, safeTransactionId); } + if (safeNonce && !data.has(NONCE_PAYLOAD_KEY)) { + data.append(NONCE_PAYLOAD_KEY, safeNonce); + } return data; } if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { @@ -150,6 +167,9 @@ if (safeTransactionId && !data.has(TRANSACTION_PAYLOAD_KEY)) { data.set(TRANSACTION_PAYLOAD_KEY, safeTransactionId); } + if (safeNonce && !data.has(NONCE_PAYLOAD_KEY)) { + data.set(NONCE_PAYLOAD_KEY, safeNonce); + } return data; } const payload = isPlainObject(data) ? { ...data } : {}; @@ -165,6 +185,9 @@ if (safeTransactionId && !Object.prototype.hasOwnProperty.call(payload, TRANSACTION_PAYLOAD_KEY)) { payload[TRANSACTION_PAYLOAD_KEY] = safeTransactionId; } + if (safeNonce && !Object.prototype.hasOwnProperty.call(payload, NONCE_PAYLOAD_KEY)) { + payload[NONCE_PAYLOAD_KEY] = safeNonce; + } return payload; }; @@ -219,6 +242,54 @@ }); }); + const requestValue = (data, name) => { + if (typeof FormData !== 'undefined' && data instanceof FormData) { + return data.get(name); + } + if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) { + return data.get(name); + } + if (isPlainObject(data)) { + return data[name]; + } + return null; + }; + + const resolveMutationTarget = (url, data) => { + const parsed = new URL(String(url || ''), window.location?.origin || 'http://localhost'); + const segments = parsed.pathname.split('/').filter(Boolean); + const endpoint = String(segments[segments.length - 1] || '').trim().toLowerCase(); + const action = String(requestValue(data, 'action') ?? parsed.searchParams.get('action') ?? '').trim().toLowerCase(); + return { endpoint, action }; + }; + + const requestMutationNonce = async (url, data, token, signal = null) => { + const target = resolveMutationTarget(url, data); + if (!target.endpoint.endsWith('.php')) { + throw new Error('Mutation nonce target is invalid.'); + } + const traceId = newTraceId(); + const noncePayload = addMutationPayloadMarkers('POST', { + action: 'issue_nonce', + endpoint: target.endpoint, + targetAction: target.action + }, token, traceId); + const response = await toAjaxPromise({ + url: NONCE_ENDPOINT, + method: 'POST', + data: noncePayload, + timeout: DEFAULT_TIMEOUT_MS, + headers: buildHeaders({}, DEFAULT_TOKEN_STORAGE_KEY, token, traceId), + dataType: 'json' + }, signal); + const payload = response?.data; + const nonce = String(payload?.nonce || payload?.data?.nonce || '').trim(); + if (!/^[a-f0-9]{64}$/.test(nonce)) { + throw new Error('A valid one-time mutation nonce was not returned.'); + } + return nonce; + }; + const shouldRetryError = (error) => { const textStatus = String(error?.textStatus || '').toLowerCase(); const status = Number(error?.jqXHR?.status || 0); @@ -317,6 +388,7 @@ contentType = undefined, dataType = undefined, xhr = undefined, + xhrFields = undefined, signal = null, onRequest = null }) => { @@ -340,7 +412,10 @@ } const traceId = newTraceId(); const transactionId = transactionIdForTrace(traceId); - const payload = addMutationPayloadMarkers(normalizedMethod, data, token, traceId); + const nonce = normalizedMethod === 'POST' + ? await requestMutationNonce(url, data, token, signal) + : ''; + const payload = addMutationPayloadMarkers(normalizedMethod, data, token, traceId, nonce); for (let attempt = 0; attempt <= safeRetries; attempt += 1) { attempts = attempt + 1; @@ -350,13 +425,14 @@ method: normalizedMethod, data: payload, timeout: safeTimeoutMs, - headers: buildHeaders(headers, tokenStorageKey, token, traceId) + headers: buildHeaders(headers, tokenStorageKey, token, traceId, nonce) }; if (typeof cache === 'boolean') ajaxOptions.cache = cache; if (typeof processData === 'boolean') ajaxOptions.processData = processData; if (contentType !== undefined) ajaxOptions.contentType = contentType; if (dataType !== undefined) ajaxOptions.dataType = dataType; if (typeof xhr === 'function') ajaxOptions.xhr = xhr; + if (xhrFields && typeof xhrFields === 'object') ajaxOptions.xhrFields = { ...xhrFields }; const response = await toAjaxPromise(ajaxOptions, signal, onRequest); response.traceId = traceId; response.transactionId = transactionId; @@ -534,6 +610,29 @@ return parseResponseJson(response, url); }; + const postBlob = async (url, data = {}, { + timeoutMs = DEFAULT_TIMEOUT_MS, + headers = {}, + tokenStorageKey = DEFAULT_TOKEN_STORAGE_KEY, + signal = null + } = {}) => { + const response = await request({ + method: 'POST', + url, + data, + timeoutMs, + retries: 0, + headers, + tokenStorageKey, + signal, + xhrFields: { responseType: 'blob' } + }); + if (typeof Blob === 'undefined' || !(response?.data instanceof Blob)) { + throw new Error(`Unexpected binary response from ${url}.`); + } + return response.data; + }; + const uploadJson = async (url, formData, { timeoutMs = 30000, headers = {}, @@ -646,6 +745,7 @@ postText, getJson, postJson, + postBlob, uploadJson, sendKeepalive, diagnostics: getDiagnostics, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/api-endpoints.json b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/api-endpoints.json index a15beb2a..78399c65 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/api-endpoints.json +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/api-endpoints.json @@ -1,10 +1,12 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "defaults": { "requestContentTypes": ["application/x-www-form-urlencoded", "multipart/form-data"], "responseContentTypes": ["application/json"], "maxRequestBytes": 2097152, - "requiredParameters": [] + "requiredParameters": [], + "replayProtection": true, + "rateLimit": {"windowSeconds": 60, "maxRequests": 180} }, "endpoints": { "apply_folder_settings.php": { @@ -17,19 +19,18 @@ "actions": { "list": {"methods": ["GET"], "access": "read-only", "requestToken": "none", "requiredParameters": ["type"], "auditCategory": "backup.list"}, "read": {"methods": ["GET"], "access": "read-only", "requestToken": "none", "requiredParameters": ["type", "name"], "auditCategory": "backup.read"}, - "download": {"methods": ["GET"], "access": "read-only", "requestToken": "none", "requiredParameters": ["type", "name"], "responseType": "binary", "responseContentTypes": ["application/json"], "auditCategory": "backup.download"}, "download_post": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type", "name"], "responseType": "binary", "responseContentTypes": ["application/json"], "auditCategory": "backup.download"}, "create": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.create"}, - "restore": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type", "name"], "auditCategory": "backup.restore"}, - "restore_latest": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.restore"}, - "restore_latest_undo": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.restore"}, + "restore": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type", "name"], "auditCategory": "backup.restore", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}}, + "restore_latest": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.restore", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}}, + "restore_latest_undo": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.restore", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}}, "delete": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type", "name"], "auditCategory": "backup.delete"}, - "delete_all": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.delete"}, + "delete_all": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "auditCategory": "backup.delete", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}}, "run_schedule": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "backup.schedule"}, "rollback_list": {"methods": ["GET"], "access": "read-only", "requestToken": "none", "requiredParameters": [], "auditCategory": "recovery.list"}, "rollback_checkpoint": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "recovery.checkpoint"}, - "rollback_restore_latest": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "recovery.restore"}, - "rollback_restore_previous": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "recovery.restore"} + "rollback_restore_latest": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "recovery.restore", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}}, + "rollback_restore_previous": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": [], "auditCategory": "recovery.restore", "rateLimit": {"windowSeconds": 60, "maxRequests": 6}} } }, "batch.php": { @@ -63,13 +64,13 @@ "report": {"methods": ["GET", "POST"], "access": "read-only", "requestToken": "none", "auditCategory": "diagnostics.report"}, "support_bundle_preview": {"methods": ["GET", "POST"], "access": "read-only", "requestToken": "none", "auditCategory": "diagnostics.support-bundle-preview"}, "support_bundle": {"methods": ["GET", "POST"], "access": "read-only", "requestToken": "none", "auditCategory": "diagnostics.support-bundle"}, - "track_event": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["eventType"], "auditCategory": "diagnostics.event"}, - "sync_docker_order": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, - "normalize_prefs": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, - "repair_config_metadata": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, - "repair_paths": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, - "repair_missing_custom_icons": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, - "repair_orphaned_members": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair"}, + "track_event": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["eventType"], "auditCategory": "diagnostics.event", "replayProtection": false, "replayExemptReason": "Page-unload telemetry uses sendBeacon and cannot synchronously acquire a one-time nonce.", "rateLimit": {"windowSeconds": 60, "maxRequests": 240}}, + "sync_docker_order": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, + "normalize_prefs": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, + "repair_config_metadata": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, + "repair_paths": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, + "repair_missing_custom_icons": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, + "repair_orphaned_members": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.repair", "rateLimit": {"windowSeconds": 60, "maxRequests": 10}}, "create_backup": {"methods": ["POST"], "access": "mutation", "requestToken": "mutation", "auditCategory": "diagnostics.backup"} } }, @@ -129,6 +130,21 @@ "methods": ["GET"], "access": "read-only", "requestToken": "none", "maxRequestBytes": 0, "requiredParameters": ["type"], "responseType": "json", "auditCategory": "runtime.snapshot" }, + "security.php": { + "methods": ["POST"], "access": "mutation", "requestToken": "bootstrap", "maxRequestBytes": 4096, + "requiredParameters": ["action", "endpoint"], "responseType": "json", "auditCategory": "security.nonce", + "replayProtection": false, "replayExemptReason": "This token-authenticated bootstrap endpoint issues the one-time nonce required by replay-protected mutations.", + "rateLimit": {"windowSeconds": 60, "maxRequests": 240}, + "actionParameter": "action", "defaultAction": "issue_nonce", + "actions": { + "issue_nonce": { + "methods": ["POST"], "access": "mutation", "requestToken": "bootstrap", + "requiredParameters": ["action", "endpoint"], "auditCategory": "security.nonce", + "replayProtection": false, "replayExemptReason": "This token-authenticated bootstrap endpoint issues the one-time nonce required by replay-protected mutations.", + "rateLimit": {"windowSeconds": 60, "maxRequests": 240} + } + } + }, "sync_order.php": { "methods": ["POST"], "access": "mutation", "requestToken": "mutation", "requiredParameters": ["type"], "responseType": "json", "auditCategory": "host.order.sync" diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/backup.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/backup.php index ce9553ea..30a51a08 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/backup.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/backup.php @@ -63,7 +63,7 @@ ? ($_POST['type'] ?? '') : ($_REQUEST['type'] ?? ''))); - $streamBackupDownload = static function (string $downloadType, string $name, string $mode = 'legacy_get'): void { + $streamBackupDownload = static function (string $downloadType, string $name): void { $downloadType = ensureType($downloadType); $path = getBackupSnapshotPath($downloadType, $name); if (!file_exists($path)) { @@ -72,7 +72,7 @@ try { appendDiagnosticsHistoryEvent('backup_download', $downloadType, [ 'name' => basename($path), - 'mode' => $mode + 'mode' => 'post_nonce' ], 'ok', 'server'); } catch (Throwable $err) { // Non-fatal. @@ -87,15 +87,9 @@ exit; }; - if ($action === 'download') { - $name = (string)($_REQUEST['name'] ?? ''); - header('X-FV-Download-Mode: legacy-get'); - $streamBackupDownload($type, $name, 'legacy_get'); - } - if ($action === 'download_post') { $name = (string)($_POST['name'] ?? ''); - $streamBackupDownload($type, $name, 'post_token'); + $streamBackupDownload($type, $name); } if ($action === 'create') { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.api-contract.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.api-contract.php index cf92b8c6..8320dbc0 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.api-contract.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.api-contract.php @@ -143,7 +143,9 @@ function fvplus_enforce_api_contract_for_endpoint(string $endpoint, ?callable $m fvplus_api_contract_assert_required($contract); - if (($contract['requestToken'] ?? 'none') === 'mutation') { + $GLOBALS['fvplusApiContractContext'] = $contract; + $requestToken = (string)($contract['requestToken'] ?? 'none'); + if ($requestToken === 'mutation') { if ($mutationGuard !== null) { $mutationGuard(); } elseif (function_exists('requireMutationRequestGuard')) { @@ -151,9 +153,13 @@ function fvplus_enforce_api_contract_for_endpoint(string $endpoint, ?callable $m } else { throw new FVPlusApiContractException('Mutation request guard is unavailable.', 500); } + } elseif ($requestToken === 'bootstrap') { + if (!function_exists('fvplus_require_nonce_bootstrap_guard')) { + throw new FVPlusApiContractException('Nonce bootstrap guard is unavailable.', 500); + } + fvplus_require_nonce_bootstrap_guard(); } - $GLOBALS['fvplusApiContractContext'] = $contract; if (!headers_sent()) { header('X-FV-API-Contract: v' . (string)(fvplus_load_api_endpoint_manifest()['schemaVersion'] ?? 1)); header('X-FV-Audit-Category: ' . (string)($contract['auditCategory'] ?? 'uncategorized')); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php index fdc4b659..a3c042fc 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php @@ -2009,7 +2009,13 @@ function diagnosticsBuildRecommendedActions(array $typesData, array $customIcons return array_values($actions); } - function diagnosticsBuildOverviewSummary(array $typesData, array $customIcons, array $update): array { + function diagnosticsBuildOverviewSummary( + array $typesData, + array $customIcons, + array $update, + array $runtimeIntegrity = [], + array $securityAudit = [] + ): array { $cards = []; $errorCount = 0; $warningCount = 0; @@ -2045,6 +2051,12 @@ function diagnosticsBuildOverviewSummary(array $typesData, array $customIcons, a ); } + if (($runtimeIntegrity['status'] ?? 'unavailable') === 'critical') { + $pathIssues[] = (string)($runtimeIntegrity['reason'] ?? 'Installed runtime integrity verification failed.'); + } + if (($securityAudit['status'] ?? 'unavailable') === 'critical') { + $pathIssues[] = 'The security audit chain failed integrity verification.'; + } $pathIssues = array_values(array_unique(array_filter(array_map('strval', $pathIssues)))); $pathIssueCount = count($pathIssues); if ($pathIssueCount > 0) { @@ -2057,7 +2069,9 @@ function diagnosticsBuildOverviewSummary(array $typesData, array $customIcons, a $pathIssueCount > 0 ? sprintf('%d path or permission issue(s) detected.', $pathIssueCount) : 'Paths look healthy.', $pathIssueCount > 0 ? implode(' ', array_slice($pathIssues, 0, 2)) - : 'Folder maps, prefs, and backups look readable and writable.', + : (($runtimeIntegrity['status'] ?? 'unavailable') === 'healthy' + ? 'Folder maps, prefs, backups, and installed runtime files passed integrity checks.' + : 'Folder maps, prefs, and backups look readable and writable.'), ['count' => $pathIssueCount] ); @@ -2226,6 +2240,8 @@ function getDiagnosticsSnapshot(string $privacyMode = FVPLUS_DIAGNOSTICS_DEFAULT $historyEvents = readDiagnosticsHistoryEvents(80); $customIcons = diagnosticsBuildCustomIconStorage($privacyMode); $update = checkRemotePluginUpdate(); + $runtimeIntegrity = fvplus_get_runtime_integrity_snapshot($privacyMode); + $securityAudit = fvplus_get_security_audit_snapshot(); return [ 'schemaVersion' => FVPLUS_DIAGNOSTICS_SCHEMA_VERSION, 'privacyMode' => $privacyMode, @@ -2233,6 +2249,8 @@ function getDiagnosticsSnapshot(string $privacyMode = FVPLUS_DIAGNOSTICS_DEFAULT 'pluginVersion' => readInstalledVersion(), 'environment' => getEnvironmentSnapshot($privacyMode), 'durableStorage' => getDurableStorageRuntimeSnapshot(), + 'runtimeIntegrity' => $runtimeIntegrity, + 'securityAudit' => $securityAudit, 'hashes' => getDiagnosticsKeyFileHashes($privacyMode), 'customIcons' => $customIcons, 'importExportHistory' => [ @@ -2242,7 +2260,7 @@ function getDiagnosticsSnapshot(string $privacyMode = FVPLUS_DIAGNOSTICS_DEFAULT ], 'recentTimeline' => buildDiagnosticsTimeline($historyEvents, 25), 'update' => $update, - 'summary' => diagnosticsBuildOverviewSummary($typesData, $customIcons, $update), + 'summary' => diagnosticsBuildOverviewSummary($typesData, $customIcons, $update, $runtimeIntegrity, $securityAudit), 'types' => $typesData ]; } @@ -2761,6 +2779,8 @@ function diagnosticsBuildSupportBundleSystemSection(array $diagnostics, array $i 'customIcons' => $customIcons ], 'durableStorage' => is_array($diagnostics['durableStorage'] ?? null) ? $diagnostics['durableStorage'] : [], + 'runtimeIntegrity' => is_array($diagnostics['runtimeIntegrity'] ?? null) ? $diagnostics['runtimeIntegrity'] : [], + 'securityAudit' => is_array($diagnostics['securityAudit'] ?? null) ? $diagnostics['securityAudit'] : [], 'phpExtensions' => array_values(get_loaded_extensions()) ]; } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php index 5aa5f8ac..f5463230 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/lib.remote.php'; require_once __DIR__ . '/lib.process.php'; require_once __DIR__ . '/lib.filesystem-security.php'; + require_once __DIR__ . '/lib.security.php'; define('FV3_DEBUG_MODE', false); // << SET TO true TO ENABLE LOGGING TO FILE >> $fv3_debug_log_file = "/tmp/folder_view3_php_debug.log"; @@ -375,6 +376,11 @@ function fv3_get_tailscale_fqdn_from_container(string $containerName, bool $cont const FVPLUS_PRIVACY_MODE_PREFS_SCHEMA = 3; const FVPLUS_CONFIG_METADATA_SCHEMA_VERSION = 1; const FVPLUS_CONFIG_MUTATION_LOCK_TIMEOUT_SECONDS = 10; + const FVPLUS_MUTATION_NONCE_TTL_SECONDS = 120; + const FVPLUS_MUTATION_NONCE_MAX_ACTIVE = 256; + const FVPLUS_MUTATION_TRANSACTION_TTL_SECONDS = 600; + const FVPLUS_MUTATION_TRANSACTION_MAX = 1000; + const FVPLUS_SECURITY_AUDIT_HISTORY_MAX = 200; const FVPLUS_CUSTOM_ICON_METADATA_SCHEMA_VERSION = 1; const FVPLUS_THEME_WORKSPACE_SCHEMA_VERSION = 1; const FVPLUS_GLOBAL_ROLLBACK_SCHEMA_VERSION = 1; @@ -1018,32 +1024,6 @@ function hasExplicitMutationRequestHeader(): bool { return $requestFlag === '1'; } - function requireMutationRequestGuard(): void { - if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') { - throw new RuntimeException('Unsupported method.'); - } - $tokenMode = getRequestTokenEnforcementMode(); - $hasMutationMarker = hasExplicitMutationRequestHeader(); - if ($tokenMode === 'strict') { - if (getConfiguredRequestToken() === '') { - throw new RuntimeException('Request token is unavailable.'); - } - if (!$hasMutationMarker || !validateOptionalRequestToken() || !isTrustedMutationContext()) { - throw new RuntimeException('Blocked by request guard.'); - } - acquireConfigMutationLock(); - return; - } - - $tokenRequiredForBypass = $tokenMode !== 'off' && getConfiguredRequestToken() !== ''; - $tokenValidated = validateOptionalRequestToken(); - $headerValidated = $hasMutationMarker && ($tokenValidated || !$tokenRequiredForBypass); - if (!isTrustedMutationContext() && !$headerValidated) { - throw new RuntimeException('Blocked by request guard.'); - } - acquireConfigMutationLock(); - } - function fvplus_json_response(array $payload, int $statusCode = 200): void { if (!headers_sent()) { header('Content-Type: application/json'); @@ -1124,6 +1104,9 @@ function fvplus_get_api_error_status(Throwable $error): int { if ($error instanceof FVPlusConfigConflictException) { return 409; } + if ($error instanceof FVPlusSecurityRequestException) { + return max(400, min(599, (int)$error->getCode())); + } if ($error instanceof InvalidArgumentException || $error instanceof RuntimeException) { return 400; } @@ -6519,7 +6502,7 @@ function fvplusShouldManageCustomIconStorageEntry(string $name, bool $includeMet return true; } if ($safeName === 'README.txt') { - return true; + return $includeMetadata; } $extension = strtolower((string)pathinfo($safeName, PATHINFO_EXTENSION)); return $extension !== '' && in_array($extension, fvplusAllowedCustomIconExtensions(), true); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php new file mode 100644 index 00000000..e6a728e3 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php @@ -0,0 +1,617 @@ + 1, + 'nonces' => array_values(is_array($state['nonces'] ?? null) ? $state['nonces'] : []), + 'transactions' => array_values(is_array($state['transactions'] ?? null) ? $state['transactions'] : []), + 'rateBuckets' => is_array($state['rateBuckets'] ?? null) ? $state['rateBuckets'] : [] + ]; +} + +function fvplus_security_with_state_lock(callable $callback) +{ + $path = fvplus_security_state_path(); + $lockPath = fvplus_security_state_lock_path(); + $parent = dirname($path); + if (!is_dir($parent) && !@mkdir($parent, 0700, true) && !is_dir($parent)) { + throw new FVPlusSecurityRequestException('Security request state is unavailable.', 503); + } + @chmod($parent, 0700); + $lock = @fopen($lockPath, 'c+'); + if (!is_resource($lock) || !@flock($lock, LOCK_EX)) { + if (is_resource($lock)) { + @fclose($lock); + } + throw new FVPlusSecurityRequestException('Security request state is busy.', 503); + } + @chmod($lockPath, 0600); + + try { + $decoded = null; + if (is_file($path)) { + $decoded = @json_decode((string)@file_get_contents($path), true); + } + $state = fvplus_security_normalize_state($decoded); + $result = null; + $callbackError = null; + try { + $result = $callback($state); + } catch (Throwable $error) { + $callbackError = $error; + } + $encoded = json_encode($state, JSON_UNESCAPED_SLASHES); + if (!is_string($encoded)) { + throw new FVPlusSecurityRequestException('Security request state could not be encoded.', 500); + } + $tmpPath = $path . '.tmp.' . bin2hex(random_bytes(6)); + if (@file_put_contents($tmpPath, $encoded, LOCK_EX) === false) { + @unlink($tmpPath); + throw new FVPlusSecurityRequestException('Security request state could not be written.', 503); + } + @chmod($tmpPath, 0600); + if (!@rename($tmpPath, $path)) { + @unlink($tmpPath); + throw new FVPlusSecurityRequestException('Security request state could not be committed.', 503); + } + @chmod($path, 0600); + if ($callbackError instanceof Throwable) { + throw $callbackError; + } + return $result; + } finally { + @flock($lock, LOCK_UN); + @fclose($lock); + } +} + +function fvplus_security_normalize_endpoint(string $endpoint): string +{ + $raw = strtolower(trim(str_replace('\\', '/', $endpoint))); + if ($raw === '' || $raw !== basename($raw)) { + return ''; + } + $endpoint = $raw; + return preg_match('/^[a-z0-9_.-]+\.php$/', $endpoint) ? $endpoint : ''; +} + +function fvplus_security_normalize_action(string $action): string +{ + $action = strtolower(trim($action)); + return preg_match('/^[a-z0-9_.:-]{0,80}$/', $action) ? $action : ''; +} + +function fvplus_security_declared_target_contract(string $endpoint, string $action): array +{ + $endpoint = fvplus_security_normalize_endpoint($endpoint); + $action = fvplus_security_normalize_action($action); + $manifest = fvplus_load_api_endpoint_manifest(); + $definition = $manifest['endpoints'][$endpoint] ?? null; + if ($endpoint === '' || !is_array($definition)) { + throw new FVPlusSecurityRequestException('Mutation nonce target is invalid.', 400); + } + $defaults = is_array($manifest['defaults'] ?? null) ? $manifest['defaults'] : []; + $effective = array_replace($defaults, $definition); + $actions = is_array($definition['actions'] ?? null) ? $definition['actions'] : null; + if (is_array($actions)) { + if ($action === '') { + $action = fvplus_security_normalize_action((string)($definition['defaultAction'] ?? '')); + } + if (!is_array($actions[$action] ?? null)) { + throw new FVPlusSecurityRequestException('Mutation nonce action is invalid.', 400); + } + $effective = array_replace($effective, $actions[$action]); + } elseif ($action !== '') { + throw new FVPlusSecurityRequestException('Mutation nonce action is not supported.', 400); + } + $methods = array_map('strtoupper', is_array($effective['methods'] ?? null) ? $effective['methods'] : []); + if (!in_array('POST', $methods, true)) { + throw new FVPlusSecurityRequestException('Mutation nonce target does not accept POST.', 405); + } + $effective['endpoint'] = $endpoint; + $effective['action'] = $action; + return $effective; +} + +function fvplus_issue_mutation_nonce(string $endpoint, string $action = ''): array +{ + $contract = fvplus_security_declared_target_contract($endpoint, $action); + $token = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $token); + $now = time(); + $expiresAt = $now + FVPLUS_MUTATION_NONCE_TTL_SECONDS; + + fvplus_security_with_state_lock(static function (array &$state) use ($contract, $tokenHash, $now, $expiresAt): void { + $state['nonces'] = array_values(array_filter($state['nonces'], static function ($row) use ($now): bool { + return is_array($row) && (int)($row['expiresAt'] ?? 0) >= $now; + })); + $state['nonces'][] = [ + 'tokenHash' => $tokenHash, + 'endpoint' => (string)$contract['endpoint'], + 'action' => (string)$contract['action'], + 'issuedAt' => $now, + 'expiresAt' => $expiresAt + ]; + if (count($state['nonces']) > FVPLUS_MUTATION_NONCE_MAX_ACTIVE) { + $state['nonces'] = array_slice($state['nonces'], -FVPLUS_MUTATION_NONCE_MAX_ACTIVE); + } + }); + + return [ + 'nonce' => $token, + 'endpoint' => (string)$contract['endpoint'], + 'action' => (string)$contract['action'], + 'expiresAt' => gmdate('c', $expiresAt) + ]; +} + +function fvplus_security_request_nonce(): string +{ + return trim((string)($_POST['_fv_nonce'] ?? getRequestHeaderValue('X-FV-Nonce'))); +} + +function fvplus_security_consume_nonce(array &$state, string $nonce, string $endpoint, string $action, int $now): void +{ + if (!preg_match('/^[a-f0-9]{64}$/', $nonce)) { + throw new FVPlusSecurityRequestException('A valid one-time mutation nonce is required.', 409); + } + $nonceHash = hash('sha256', $nonce); + $matched = null; + $remaining = []; + foreach ($state['nonces'] as $row) { + if (!is_array($row) || (int)($row['expiresAt'] ?? 0) < $now) { + continue; + } + if ($matched === null && hash_equals((string)($row['tokenHash'] ?? ''), $nonceHash)) { + $matched = $row; + continue; + } + $remaining[] = $row; + } + $state['nonces'] = $remaining; + if (!is_array($matched)) { + throw new FVPlusSecurityRequestException('Mutation nonce is expired, invalid, or already used.', 409); + } + if (!hash_equals((string)($matched['endpoint'] ?? ''), $endpoint) + || !hash_equals((string)($matched['action'] ?? ''), $action)) { + throw new FVPlusSecurityRequestException('Mutation nonce does not match this operation.', 409); + } +} + +function fvplus_security_consume_transaction(array &$state, string $transactionId, string $endpoint, string $action, int $now): void +{ + $transactionId = normalizeRequestTransactionId($transactionId); + if ($transactionId === '' || $transactionId === 'tx-fallback') { + throw new FVPlusSecurityRequestException('A valid mutation transaction ID is required.', 409); + } + $retained = []; + foreach ($state['transactions'] as $row) { + if (!is_array($row) || (int)($row['expiresAt'] ?? 0) < $now) { + continue; + } + if (hash_equals((string)($row['transactionId'] ?? ''), $transactionId)) { + throw new FVPlusSecurityRequestException('Duplicate mutation transaction rejected.', 409); + } + $retained[] = $row; + } + $retained[] = [ + 'transactionId' => $transactionId, + 'endpoint' => $endpoint, + 'action' => $action, + 'expiresAt' => $now + FVPLUS_MUTATION_TRANSACTION_TTL_SECONDS + ]; + $state['transactions'] = array_slice($retained, -FVPLUS_MUTATION_TRANSACTION_MAX); +} + +function fvplus_security_enforce_rate_limit(array &$state, array $contract, int $now): void +{ + $rateLimit = is_array($contract['rateLimit'] ?? null) ? $contract['rateLimit'] : []; + $windowSeconds = max(1, min(3600, (int)($rateLimit['windowSeconds'] ?? 60))); + $maxRequests = max(1, min(10000, (int)($rateLimit['maxRequests'] ?? 180))); + $category = substr(trim((string)($contract['auditCategory'] ?? 'mutation')), 0, 120); + $bucketKey = hash('sha256', $category); + $windowStart = $now - $windowSeconds; + $timestamps = array_values(array_filter( + is_array($state['rateBuckets'][$bucketKey] ?? null) ? $state['rateBuckets'][$bucketKey] : [], + static fn($timestamp): bool => (int)$timestamp > $windowStart + )); + if (count($timestamps) >= $maxRequests) { + if (!headers_sent()) { + $retryAfter = max(1, ((int)$timestamps[0] + $windowSeconds) - $now); + header('Retry-After: ' . $retryAfter); + } + throw new FVPlusSecurityRequestException('This operation is temporarily rate limited. Try again shortly.', 429); + } + $timestamps[] = $now; + $state['rateBuckets'][$bucketKey] = $timestamps; + foreach ($state['rateBuckets'] as $key => $rows) { + $filtered = array_values(array_filter( + is_array($rows) ? $rows : [], + static fn($timestamp): bool => (int)$timestamp > ($now - 3600) + )); + if (count($filtered) === 0) { + unset($state['rateBuckets'][$key]); + } else { + $state['rateBuckets'][$key] = array_slice($filtered, -10000); + } + } +} + +function fvplus_security_current_contract(): array +{ + $contract = $GLOBALS['fvplusApiContractContext'] ?? null; + if (is_array($contract) && !empty($contract['endpoint'])) { + return $contract; + } + $endpoint = fvplus_current_api_endpoint_name(); + return $endpoint !== '' ? fvplus_resolve_api_endpoint_contract($endpoint) : []; +} + +function fvplus_enforce_mutation_security_controls(): array +{ + $contract = fvplus_security_current_contract(); + $endpoint = fvplus_security_normalize_endpoint((string)($contract['endpoint'] ?? fvplus_current_api_endpoint_name())); + $action = fvplus_security_normalize_action((string)($contract['action'] ?? '')); + if ($endpoint === '') { + throw new FVPlusSecurityRequestException('Mutation endpoint context is unavailable.', 500); + } + $now = time(); + $replayProtection = ($contract['replayProtection'] ?? true) !== false; + $nonce = fvplus_security_request_nonce(); + $transactionId = getRequestTransactionId(); + + fvplus_security_with_state_lock(static function (array &$state) use ( + $contract, + $endpoint, + $action, + $now, + $replayProtection, + $nonce, + $transactionId + ): void { + if ($replayProtection) { + fvplus_security_consume_nonce($state, $nonce, $endpoint, $action, $now); + fvplus_security_consume_transaction($state, $transactionId, $endpoint, $action, $now); + } + fvplus_security_enforce_rate_limit($state, $contract, $now); + }); + + fvplus_append_security_audit_event('mutation_authorized', 'ok', [ + 'endpoint' => $endpoint, + 'action' => $action, + 'auditCategory' => (string)($contract['auditCategory'] ?? 'mutation'), + 'replayProtected' => $replayProtection + ]); + return $contract; +} + +function fvplus_require_nonce_bootstrap_guard(): void +{ + if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') { + throw new FVPlusSecurityRequestException('Unsupported method.', 405); + } + if (!hasExplicitMutationRequestHeader() || !validateOptionalRequestToken() || !isTrustedMutationContext()) { + throw new FVPlusSecurityRequestException('Blocked by request guard.', 403); + } + $contract = fvplus_security_current_contract(); + $now = time(); + fvplus_security_with_state_lock(static function (array &$state) use ($contract, $now): void { + fvplus_security_enforce_rate_limit($state, $contract, $now); + }); +} + +function fvplus_security_audit_path(): string +{ + global $configDir; + return "$configDir/security.audit.json"; +} + +function fvplus_security_canonicalize($value) +{ + if (!is_array($value)) { + return $value; + } + if (array_is_list($value)) { + return array_map('fvplus_security_canonicalize', $value); + } + ksort($value, SORT_STRING); + foreach ($value as $key => $item) { + $value[$key] = fvplus_security_canonicalize($item); + } + return $value; +} + +function fvplus_security_event_hash(array $event, string $secret): string +{ + unset($event['eventHash']); + $encoded = json_encode(fvplus_security_canonicalize($event), JSON_UNESCAPED_SLASHES); + $payload = is_string($encoded) ? $encoded : '{}'; + return $secret !== '' ? hash_hmac('sha256', $payload, $secret) : hash('sha256', $payload); +} + +function fvplus_append_security_audit_event(string $eventName, string $status, array $context = []): array +{ + $path = fvplus_security_audit_path(); + $events = readJsonObjectFile($path); + if (!is_array($events)) { + $events = recoverJsonObjectFromLastGood($path); + } + $events = is_array($events) ? array_values($events) : []; + $previousHash = ''; + if (count($events) > 0) { + $previousHash = (string)($events[count($events) - 1]['eventHash'] ?? ''); + } + $event = [ + 'schemaVersion' => 1, + 'id' => generateId(16), + 'timestamp' => gmdate('c'), + 'event' => substr(trim($eventName), 0, 80), + 'status' => substr(trim($status), 0, 24), + 'endpoint' => fvplus_security_normalize_endpoint((string)($context['endpoint'] ?? '')), + 'action' => fvplus_security_normalize_action((string)($context['action'] ?? '')), + 'auditCategory' => substr(trim((string)($context['auditCategory'] ?? 'security')), 0, 120), + 'replayProtected' => (bool)($context['replayProtected'] ?? false), + 'traceId' => getRequestTraceId(), + 'transactionId' => getRequestTransactionId(), + 'previousHash' => $previousHash + ]; + $event['eventHash'] = fvplus_security_event_hash($event, getConfiguredRequestToken()); + $events[] = $event; + if (count($events) > FVPLUS_SECURITY_AUDIT_HISTORY_MAX) { + $events = array_slice($events, -FVPLUS_SECURITY_AUDIT_HISTORY_MAX); + if (count($events) > 0) { + $events[0]['previousHash'] = ''; + $events[0]['eventHash'] = fvplus_security_event_hash($events[0], getConfiguredRequestToken()); + for ($index = 1; $index < count($events); $index++) { + $events[$index]['previousHash'] = (string)$events[$index - 1]['eventHash']; + $events[$index]['eventHash'] = fvplus_security_event_hash($events[$index], getConfiguredRequestToken()); + } + } + } + writeJsonObjectWithLastGood($path, $events); + return $event; +} + +function fvplus_get_security_audit_snapshot(): array +{ + $events = readJsonObjectFile(fvplus_security_audit_path()); + if (!is_array($events)) { + $events = recoverJsonObjectFromLastGood(fvplus_security_audit_path()); + } + if (!is_array($events)) { + return [ + 'status' => 'unavailable', + 'retained' => 0, + 'chainValid' => null, + 'checkedAt' => gmdate('c') + ]; + } + $secret = getConfiguredRequestToken(); + $previousHash = ''; + $valid = true; + $brokenIndex = null; + foreach (array_values($events) as $index => $event) { + if (!is_array($event) + || !hash_equals($previousHash, (string)($event['previousHash'] ?? '')) + || !hash_equals((string)($event['eventHash'] ?? ''), fvplus_security_event_hash($event, $secret))) { + $valid = false; + $brokenIndex = $index; + break; + } + $previousHash = (string)$event['eventHash']; + } + return [ + 'status' => $valid ? 'healthy' : 'critical', + 'retained' => count($events), + 'chainValid' => $valid, + 'brokenIndex' => $brokenIndex, + 'checkedAt' => gmdate('c') + ]; +} + +function fvplus_runtime_integrity_should_track(string $relativePath): bool +{ + $relativePath = ltrim(str_replace('\\', '/', $relativePath), '/'); + if ($relativePath === '' || $relativePath === 'runtime-integrity.json') { + return false; + } + if (preg_match('/^[^\/]+\.(?:page|php|json)$/i', $relativePath)) { + return true; + } + if (!preg_match('#^(?:server|scripts|styles)/#', $relativePath)) { + return false; + } + return (bool)preg_match('/\.(?:php|js|sh|css|json)$/i', $relativePath); +} + +function fvplus_get_runtime_integrity_snapshot(string $privacyMode = 'sanitized'): array +{ + global $sourceDir; + $manifestPath = rtrim($sourceDir, '/\\') . '/runtime-integrity.json'; + $runtimeRoot = realpath($sourceDir); + $runtimeRoot = is_string($runtimeRoot) ? rtrim(str_replace('\\', '/', $runtimeRoot), '/') : ''; + $checkedAt = gmdate('c'); + if ($runtimeRoot === '' || !is_file($manifestPath)) { + return [ + 'status' => 'unavailable', + 'checkedAt' => $checkedAt, + 'reason' => 'Runtime integrity manifest is unavailable.', + 'expectedCount' => 0, + 'checkedCount' => 0, + 'missingCount' => 0, + 'modifiedCount' => 0, + 'unexpectedCount' => 0, + 'modeChangedCount' => 0 + ]; + } + $manifest = @json_decode((string)@file_get_contents($manifestPath), true); + $entries = is_array($manifest['files'] ?? null) ? array_values($manifest['files']) : []; + if (!is_array($manifest) || (int)($manifest['schemaVersion'] ?? 0) !== 1 || count($entries) > 1000) { + return [ + 'status' => 'critical', + 'checkedAt' => $checkedAt, + 'reason' => 'Runtime integrity manifest is invalid.', + 'expectedCount' => count($entries), + 'checkedCount' => 0, + 'missingCount' => 0, + 'modifiedCount' => 0, + 'unexpectedCount' => 0, + 'modeChangedCount' => 0 + ]; + } + + $expected = []; + $findings = []; + $missingCount = 0; + $modifiedCount = 0; + $modeChangedCount = 0; + $checkedCount = 0; + foreach ($entries as $entry) { + if (!is_array($entry)) { + $modifiedCount++; + continue; + } + $relative = ltrim(str_replace('\\', '/', (string)($entry['path'] ?? '')), '/'); + if (!fvplus_runtime_integrity_should_track($relative) || str_contains($relative, '../')) { + $modifiedCount++; + continue; + } + $expected[$relative] = true; + $absolute = rtrim($sourceDir, '/\\') . '/' . $relative; + $resolvedAbsolute = realpath($absolute); + $resolvedAbsolute = is_string($resolvedAbsolute) ? str_replace('\\', '/', $resolvedAbsolute) : ''; + if (!is_file($absolute) + || is_link($absolute) + || $resolvedAbsolute === '' + || !str_starts_with($resolvedAbsolute, $runtimeRoot . '/')) { + $missingCount++; + $findings[] = ['kind' => 'missing', 'path' => $relative]; + continue; + } + $checkedCount++; + $actualHash = @hash_file('sha256', $absolute); + if (!is_string($actualHash) || !hash_equals((string)($entry['sha256'] ?? ''), $actualHash)) { + $modifiedCount++; + $findings[] = ['kind' => 'modified', 'path' => $relative]; + } + $expectedMode = (string)($entry['mode'] ?? '0755'); + $actualMode = sprintf('%04o', ((int)@fileperms($absolute)) & 0777); + if ($expectedMode !== '' && $expectedMode !== $actualMode) { + $modeChangedCount++; + $findings[] = ['kind' => 'mode', 'path' => $relative]; + } + } + + $unexpectedCount = 0; + $scanRoots = [ + rtrim($sourceDir, '/\\') . '/server', + rtrim($sourceDir, '/\\') . '/scripts', + rtrim($sourceDir, '/\\') . '/styles' + ]; + $candidates = []; + foreach ($scanRoots as $scanRoot) { + if (!is_dir($scanRoot)) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($scanRoot, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + $candidates[] = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen(rtrim($sourceDir, '/\\')) + 1)); + } + } + foreach (glob(rtrim($sourceDir, '/\\') . '/*') ?: [] as $rootEntry) { + if (is_file($rootEntry)) { + $candidates[] = basename($rootEntry); + } + } + foreach (array_values(array_unique($candidates)) as $relative) { + if (fvplus_runtime_integrity_should_track($relative) && !isset($expected[$relative])) { + $unexpectedCount++; + $findings[] = ['kind' => 'unexpected', 'path' => $relative]; + } + } + + $problemCount = $missingCount + $modifiedCount + $unexpectedCount + $modeChangedCount; + $safeFindings = []; + foreach (array_slice($findings, 0, 25) as $finding) { + $path = (string)($finding['path'] ?? ''); + $safeFindings[] = [ + 'kind' => (string)($finding['kind'] ?? 'unknown'), + 'path' => strtolower(trim($privacyMode)) === 'full' + ? $path + : 'file-' . substr(hash('sha256', $path), 0, 12) + ]; + } + return [ + 'status' => $problemCount > 0 ? 'critical' : 'healthy', + 'checkedAt' => $checkedAt, + 'reason' => $problemCount > 0 + ? sprintf('%d installed runtime integrity finding(s) detected.', $problemCount) + : 'Installed runtime files match the packaged integrity manifest.', + 'algorithm' => 'sha256', + 'expectedCount' => count($entries), + 'checkedCount' => $checkedCount, + 'missingCount' => $missingCount, + 'modifiedCount' => $modifiedCount, + 'unexpectedCount' => $unexpectedCount, + 'modeChangedCount' => $modeChangedCount, + 'findings' => $safeFindings + ]; +} diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/security.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/security.php new file mode 100644 index 00000000..336e53c3 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/security.php @@ -0,0 +1,13 @@ + { .filter((name) => name.endsWith('.php') && !name.startsWith('lib')) .sort(); assert.deepEqual(Object.keys(manifest.endpoints).sort(), publicEndpoints); - assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.schemaVersion, 2); }); test('previously omitted mutation surfaces are explicitly classified', () => { diff --git a/tests/durable-storage-service.test.mjs b/tests/durable-storage-service.test.mjs index d3138bba..cbc70b20 100644 --- a/tests/durable-storage-service.test.mjs +++ b/tests/durable-storage-service.test.mjs @@ -19,6 +19,9 @@ const directWriteAllowlist = new Map([ ['fvplus_log_api_exception', 1], ['markDockerSyncOrderPending', 1] ])], + ['lib.security.php', new Map([ + ['fvplus_security_with_state_lock', 1] + ])], ['third_party_icons.php', new Map([ ['writeThirdPartyIconCache', 2] ])], diff --git a/tests/release-flow-regression.test.mjs b/tests/release-flow-regression.test.mjs index 9d0c7459..678ea894 100644 --- a/tests/release-flow-regression.test.mjs +++ b/tests/release-flow-regression.test.mjs @@ -107,6 +107,7 @@ test('pkg_build dry-run falls back to manifest branch when git branch detection }); copyFileIntoTemp(tempRoot, 'pkg_build.sh'); + copyFileIntoTemp(tempRoot, 'scripts/generate_runtime_integrity_manifest.mjs'); writeFile(path.join(tempRoot, 'folderview.plus.plg'), ` diff --git a/tests/request-client.test.mjs b/tests/request-client.test.mjs index de1bbdff..9e105df3 100644 --- a/tests/request-client.test.mjs +++ b/tests/request-client.test.mjs @@ -16,11 +16,15 @@ const createJQueryMock = (plan = []) => { const ajaxPrefilters = []; const ajaxCalls = []; let callCount = 0; + let plannedCallCount = 0; const ajax = (options) => { callCount += 1; ajaxCalls.push(options); - const step = plan[callCount - 1] || { type: 'success', data: '{}' }; + const isNonceRequest = options?.url === '/plugins/folderview.plus/server/security.php'; + const step = isNonceRequest + ? { type: 'success', data: { ok: true, nonce: 'a'.repeat(64) } } + : (plan[plannedCallCount++] || { type: 'success', data: '{}' }); let doneHandler = null; let failHandler = null; let settled = false; @@ -148,7 +152,9 @@ test('request client generates trace IDs and sends them on mutation payload + he }); assert.equal(response.ok, true); - const call = getAjaxCalls()[0] || {}; + const nonceCall = getAjaxCalls()[0] || {}; + const call = getAjaxCalls()[1] || {}; + assert.equal(nonceCall.url, '/plugins/folderview.plus/server/security.php'); assert.equal(call.method, 'POST'); assert.match(String(call.headers?.['X-FV-Trace'] || ''), /^fv-/); assert.match(String(call.headers?.['X-FV-Transaction'] || ''), /^tx-/); @@ -156,6 +162,8 @@ test('request client generates trace IDs and sends them on mutation payload + he assert.equal(call.data.token, 'tok-123'); assert.equal(call.data._fv_trace, call.headers?.['X-FV-Trace']); assert.equal(call.data._fv_transaction, call.headers?.['X-FV-Transaction']); + assert.equal(call.data._fv_nonce, 'a'.repeat(64)); + assert.equal(call.headers?.['X-FV-Nonce'], 'a'.repeat(64)); assert.equal(response.transactionId, call.headers?.['X-FV-Transaction']); }); @@ -188,7 +196,7 @@ test('request client does not retry aborted requests', async () => { () => api.postJson('/plugins/folderview.plus/server/test.php', { ok: 1 }, { retries: 3 }), /trace:\s*fv-/ ); - assert.equal(getCallCount(), 1); + assert.equal(getCallCount(), 2); }); test('request client surfaces backend JSON error details in thrown message', async () => { @@ -211,7 +219,7 @@ test('request client surfaces backend JSON error details in thrown message', asy () => api.postJson('/plugins/folderview.plus/server/update.php', { type: 'docker' }, { retries: 0 }), /Missing required parameters/ ); - assert.equal(getCallCount(), 1); + assert.equal(getCallCount(), 2); }); test('request client preserves HTTP status and response details for conflict recovery', async () => { @@ -247,10 +255,11 @@ test('request client appends mutation markers to POST payload for guard compatib }); assert.equal(response.ok, true); - const call = getAjaxCalls()[0] || {}; + const call = getAjaxCalls()[1] || {}; assert.equal(call.method, 'POST'); assert.equal(call.data._fv_request, '1'); assert.equal(call.data.token, 'tok-123'); + assert.equal(call.data._fv_nonce, 'a'.repeat(64)); }); test('request client retries reads but never replays mutations by default', async () => { @@ -274,7 +283,7 @@ test('request client retries reads but never replays mutations by default', asyn () => mutationClient.api.postJson('/plugins/folderview.plus/server/update.php', { type: 'docker' }), /HTTP 503/ ); - assert.equal(mutationClient.getCallCount(), 1); + assert.equal(mutationClient.getCallCount(), 2); }); test('request client builds encoded URLs and exposes bounded sanitized diagnostics', async () => { diff --git a/tests/security-hardening.test.mjs b/tests/security-hardening.test.mjs index a88836e8..fd9e22ce 100644 --- a/tests/security-hardening.test.mjs +++ b/tests/security-hardening.test.mjs @@ -15,6 +15,7 @@ const pluginPageSources = pluginPageFiles.map((entry) => ({ })); const libPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php'); +const libSecurityPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.security.php'); const libDiagnosticsPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php'); const backupPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/backup.php'); const dockerJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'); @@ -50,11 +51,11 @@ test('lib.php keeps token rollout controls and secure API headers', () => { assert.match(libPhp, /X-Content-Type-Options: nosniff/); }); -test('backup endpoint supports guarded POST download and legacy fallback', () => { +test('backup endpoint supports only the guarded POST download', () => { assert.match(backupPhp, /\$guardedReadActions\s*=\s*\['download_post'\]/); assert.match(backupPhp, /if \(\$action === 'download_post'\)/); - assert.match(backupPhp, /if \(\$action === 'download'\)/); - assert.match(backupPhp, /X-FV-Download-Mode: legacy-get/); + assert.doesNotMatch(backupPhp, /if \(\$action === 'download'\)/); + assert.doesNotMatch(backupPhp, /legacy-get/); assert.match(backupPhp, /X-Content-Type-Options: nosniff/); }); @@ -211,15 +212,15 @@ test('folder editor supports unicode names and secure guarded create/update post test('strict request guard requires the mutation marker, token, and same-origin context', () => { assert.match(libPhp, /function hasExplicitMutationRequestHeader\(\): bool/); assert.match(libPhp, /\$_POST\['_fv_request'\] \?\? \$_GET\['_fv_request'\] \?\? ''/); - assert.match(libPhp, /if \(\$tokenMode === 'strict'\)/); - assert.match(libPhp, /if \(getConfiguredRequestToken\(\) === ''\)/); - assert.match(libPhp, /!\$hasMutationMarker \|\| !validateOptionalRequestToken\(\) \|\| !isTrustedMutationContext\(\)/); - assert.match(requestClientJs, /const addMutationPayloadMarkers = \(method, data, token, traceId = ''\) =>/); + assert.match(libSecurityPhp, /if \(\$tokenMode === 'strict'\)/); + assert.match(libSecurityPhp, /if \(getConfiguredRequestToken\(\) === ''\)/); + assert.match(libSecurityPhp, /!\$hasMutationMarker \|\| !validateOptionalRequestToken\(\) \|\| !isTrustedMutationContext\(\)/); + assert.match(requestClientJs, /const addMutationPayloadMarkers = \(method, data, token, traceId = '', nonce = ''\) =>/); assert.match(requestClientJs, /payload\._fv_request = '1';/); assert.match(requestClientJs, /normalizedMethod === 'POST' && !token/); assert.match(requestClientJs, /outcome: 'blocked-missing-token'/); assert.doesNotMatch(requestClientJs, /localStorage\.getItem\(tokenStorageKey\)/); - assert.match(folderViewPlusJs, /addField\('_fv_request', '1'\)/); + assert.match(folderViewPlusJs, /requestClient\.postBlob\('\/plugins\/folderview\.plus\/server\/backup\.php'/); assert.match(folderViewPlusJs, /requestClient\.postJson\(url, data, options\)/); }); diff --git a/tests/security-release-contract.test.mjs b/tests/security-release-contract.test.mjs index cda6f9d6..e678e412 100644 --- a/tests/security-release-contract.test.mjs +++ b/tests/security-release-contract.test.mjs @@ -14,6 +14,28 @@ test('CodeQL scans dev and main for pushes and pull requests', () => { assert.match(workflow, /push:\s*\n\s*branches:\s*\n\s*- dev\s*\n\s*- main/); assert.match(workflow, /pull_request:\s*\n\s*branches:\s*\n\s*- dev\s*\n\s*- main/); assert.match(workflow, /queries: security-extended,security-and-quality/); + assert.equal((workflow.match(/github\/codeql-action\/(?:init|autobuild|analyze)@[0-9a-f]{40}\s+# v4/g) || []).length, 3); +}); + +test('dependency review blocks vulnerable or unapproved dependency changes', () => { + const workflow = read('.github/workflows/dependency-review.yml'); + assert.match(workflow, /pull_request:\s*\n\s*branches:\s*\n\s*- dev\s*\n\s*- main/); + assert.match(workflow, /actions\/dependency-review-action@[0-9a-f]{40}\s+# v5/); + assert.match(workflow, /fail-on-severity: high/); + assert.match(workflow, /license-check: true/); + assert.match(workflow, /allow-licenses: Apache-2\.0, BSD-3-Clause, BlueOak-1\.0\.0, ISC, MIT, MPL-2\.0/); + assert.match(workflow, /warn-only: false/); +}); + +test('OpenSSF Scorecard publishes pinned SARIF results on a schedule', () => { + const workflow = read('.github/workflows/scorecard.yml'); + assert.match(workflow, /schedule:/); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /ossf\/scorecard-action@[0-9a-f]{40}\s+# v2\.4\.4/); + assert.match(workflow, /github\/codeql-action\/upload-sarif@[0-9a-f]{40}\s+# v4/); + assert.match(workflow, /publish_results: true/); + assert.match(workflow, /security-events: write/); + assert.match(workflow, /id-token: write/); }); test('workflows never upload live Unraid browser evidence', () => { diff --git a/tests/security-update-3.test.mjs b/tests/security-update-3.test.mjs new file mode 100644 index 00000000..66c96d64 --- /dev/null +++ b/tests/security-update-3.test.mjs @@ -0,0 +1,194 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = path.resolve(process.cwd()); +const pluginRoot = path.join(root, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus'); +const libPath = path.join(pluginRoot, 'server/lib.php'); +const generatorPath = path.join(root, 'scripts/generate_runtime_integrity_manifest.mjs'); +const phpString = (value) => `'${String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`; + +function runPhp(source, env = {}) { + const result = spawnSync('php', ['-r', source], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, ...env } + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + +test('mutation nonces are target-bound, single-use, and backed by transaction replay rejection', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'fv-security-nonce-')); + try { + const statePath = path.join(temp, 'security-state.json'); + const configDir = path.join(temp, 'config'); + const result = runPhp(` + require_once(${phpString(libPath)}); + $first=fvplus_issue_mutation_nonce('create.php', ''); + $second=fvplus_issue_mutation_nonce('create.php', ''); + $mismatch=fvplus_issue_mutation_nonce('delete.php', ''); + $results=[]; + fvplus_security_with_state_lock(function(array &$state) use ($first) { + fvplus_security_consume_nonce($state, $first['nonce'], 'create.php', '', time()); + fvplus_security_consume_transaction($state, 'tx-security-one', 'create.php', '', time()); + }); + try { + fvplus_security_with_state_lock(function(array &$state) use ($first) { + fvplus_security_consume_nonce($state, $first['nonce'], 'create.php', '', time()); + }); + $results['nonceReplay']='accepted'; + } catch (FVPlusSecurityRequestException $error) { + $results['nonceReplay']=$error->getCode(); + } + try { + fvplus_security_with_state_lock(function(array &$state) use ($mismatch) { + fvplus_security_consume_nonce($state, $mismatch['nonce'], 'create.php', '', time()); + }); + $results['targetMismatch']='accepted'; + } catch (FVPlusSecurityRequestException $error) { + $results['targetMismatch']=$error->getCode(); + } + try { + fvplus_security_with_state_lock(function(array &$state) use ($second) { + fvplus_security_consume_nonce($state, $second['nonce'], 'create.php', '', time()); + fvplus_security_consume_transaction($state, 'tx-security-one', 'create.php', '', time()); + }); + $results['transactionReplay']='accepted'; + } catch (FVPlusSecurityRequestException $error) { + $results['transactionReplay']=$error->getCode(); + } + try { + fvplus_security_declared_target_contract('../delete.php', ''); + $results['pathTraversal']='accepted'; + } catch (FVPlusSecurityRequestException $error) { + $results['pathTraversal']=$error->getCode(); + } + echo json_encode($results); + `, { + FVPLUS_TEST_CONFIG_DIR: configDir, + FVPLUS_TEST_SOURCE_DIR: pluginRoot, + FVPLUS_TEST_SECURITY_STATE_PATH: statePath + }); + assert.deepEqual(result, { + nonceReplay: 409, + targetMismatch: 409, + transactionReplay: 409, + pathTraversal: 400 + }); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test('mutation rate buckets fail closed with HTTP 429 semantics', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'fv-security-rate-')); + try { + const result = runPhp(` + require_once(${phpString(libPath)}); + $contract=['auditCategory'=>'test.rate','rateLimit'=>['windowSeconds'=>60,'maxRequests'=>2]]; + $code=0; + fvplus_security_with_state_lock(function(array &$state) use ($contract) { + fvplus_security_enforce_rate_limit($state, $contract, time()); + fvplus_security_enforce_rate_limit($state, $contract, time()); + }); + try { + fvplus_security_with_state_lock(function(array &$state) use ($contract) { + fvplus_security_enforce_rate_limit($state, $contract, time()); + }); + } catch (FVPlusSecurityRequestException $error) { + $code=$error->getCode(); + } + echo json_encode(['code'=>$code]); + `, { + FVPLUS_TEST_CONFIG_DIR: path.join(temp, 'config'), + FVPLUS_TEST_SOURCE_DIR: pluginRoot, + FVPLUS_TEST_SECURITY_STATE_PATH: path.join(temp, 'state.json') + }); + assert.equal(result.code, 429); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test('security audit records are HMAC chained and tampering is detected', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'fv-security-audit-')); + try { + const configDir = path.join(temp, 'config'); + const result = runPhp(` + require_once(${phpString(libPath)}); + fvplus_append_security_audit_event('mutation_authorized','ok',[ + 'endpoint'=>'create.php','action'=>'','auditCategory'=>'folder.create','replayProtected'=>true + ]); + fvplus_append_security_audit_event('mutation_authorized','ok',[ + 'endpoint'=>'delete.php','action'=>'','auditCategory'=>'folder.delete','replayProtected'=>true + ]); + $before=fvplus_get_security_audit_snapshot(); + $path=fvplus_security_audit_path(); + $events=json_decode(file_get_contents($path),true); + $events[0]['status']='tampered'; + file_put_contents($path,json_encode($events)); + $after=fvplus_get_security_audit_snapshot(); + echo json_encode(['before'=>$before,'after'=>$after,'events'=>$events]); + `, { + FVPLUS_TEST_CONFIG_DIR: configDir, + FVPLUS_TEST_SOURCE_DIR: pluginRoot, + FVPLUS_TEST_SECURITY_STATE_PATH: path.join(temp, 'state.json') + }); + assert.equal(result.before.status, 'healthy'); + assert.equal(result.before.chainValid, true); + assert.equal(result.after.status, 'critical'); + assert.equal(result.after.chainValid, false); + assert.equal(result.events.length, 2); + assert.deepEqual( + Object.keys(result.events[0]).sort(), + [ + 'action', 'auditCategory', 'endpoint', 'event', 'eventHash', 'id', + 'previousHash', 'replayProtected', 'schemaVersion', 'status', + 'timestamp', 'traceId', 'transactionId' + ].sort() + ); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); + +test('packaged runtime manifest excludes unrelated assets and detects modified executable code', () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'fv-runtime-integrity-')); + try { + fs.mkdirSync(path.join(temp, 'server'), { recursive: true }); + fs.mkdirSync(path.join(temp, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(temp, 'images'), { recursive: true }); + fs.writeFileSync(path.join(temp, 'Folder.page'), ''); + const manifestPath = path.join(temp, 'runtime-integrity.json'); + const generated = spawnSync(process.execPath, [ + generatorPath, '--root', temp, '--output', manifestPath + ], { cwd: root, encoding: 'utf8' }); + assert.equal(generated.status, 0, generated.stderr || generated.stdout); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.deepEqual( + manifest.files.map((entry) => entry.path), + ['Folder.page', 'scripts/test.js', 'server/test.php'] + ); + fs.appendFileSync(path.join(temp, 'server/test.php'), '\n// modified'); + const snapshot = runPhp(` + require_once(${phpString(libPath)}); + echo json_encode(fvplus_get_runtime_integrity_snapshot('full')); + `, { + FVPLUS_TEST_CONFIG_DIR: path.join(temp, 'config'), + FVPLUS_TEST_SOURCE_DIR: temp, + FVPLUS_TEST_SECURITY_STATE_PATH: path.join(temp, 'state.json') + }); + assert.equal(snapshot.status, 'critical'); + assert.ok(snapshot.modifiedCount >= 1); + assert.ok(snapshot.findings.some((finding) => finding.kind === 'modified' && finding.path === 'server/test.php')); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/tests/server-response-helpers.test.mjs b/tests/server-response-helpers.test.mjs index 6bd6bb5d..51bc45cb 100644 --- a/tests/server-response-helpers.test.mjs +++ b/tests/server-response-helpers.test.mjs @@ -95,6 +95,7 @@ test('lib.php repairs custom icon directories and can clear missing custom icon assert.match(libPhp, /return "\$configDir\/images\/custom";/); assert.match(libPhp, /function fvplusCustomIconRuntimeDirPath\(\): string/); assert.match(libPhp, /return "\$sourceDir\/images\/custom";/); + assert.match(libPhp, /if \(\$safeName === 'README\.txt'\) \{\s*return \$includeMetadata;/); assert.match(libPhp, /function fvplusEnsureCustomIconStorageReady\(bool \$requireWritable = false\): array/); assert.match(libPhp, /function fvplusBootstrapCustomIconStorage\(\): void/); assert.match(libPhp, /fvplusBootstrapCustomIconStorage\(\);/); @@ -228,7 +229,7 @@ test('lib.php diagnostics include user-facing summary cards and recommended acti assert.match(libDiagnosticsPhp, /function diagnosticsBuildRecommendedActions\s*\(/); assert.match(libDiagnosticsPhp, /function diagnosticsBuildOverviewSummary\s*\(/); assert.match(libDiagnosticsPhp, /'recommendedActions'\s*=>\s*diagnosticsBuildRecommendedActions\(\$typesData, \$customIcons\)/); - assert.match(libDiagnosticsPhp, /'summary'\s*=>\s*diagnosticsBuildOverviewSummary\(\$typesData, \$customIcons, \$update\)/); + assert.match(libDiagnosticsPhp, /'summary'\s*=>\s*diagnosticsBuildOverviewSummary\(\$typesData, \$customIcons, \$update, \$runtimeIntegrity, \$securityAudit\)/); assert.match(libDiagnosticsPhp, /foreach\s*\(\['docker'\s*=>\s*'Docker config',\s*'vm'\s*=>\s*'VM config'\]/); assert.match(libDiagnosticsPhp, /'Storage and paths'/); assert.match(libDiagnosticsPhp, /'Custom icons'/); diff --git a/tests/versioning-guard.test.mjs b/tests/versioning-guard.test.mjs index 3e3f078a..123dd9e4 100644 --- a/tests/versioning-guard.test.mjs +++ b/tests/versioning-guard.test.mjs @@ -193,6 +193,12 @@ test('release_guard blocks future-dated versions', () => { assert.match(releaseGuard, /TODAY_DATE="\$\(date \+\"%Y\.%m\.%d\"\)"/); }); +test('release_guard validates pull-request packages against the protected base channel', () => { + assert.match(releaseGuard, /GITHUB_BASE_REF/); + assert.match(releaseGuard, /GITHUB_BASE_REF:-}" =~ \^\(main\|dev\)\$/); + assert.match(releaseGuard, /EXPECTED_PLUGIN_BRANCH="\$\{GITHUB_BASE_REF\}"/); +}); + test('release_guard enforces explicit changelog category contract for current version', () => { assert.match(releaseGuard, /CURRENT_CHANGES_BLOCK="\$\(awk -v version="\$\{VERSION\}"/); assert.match(releaseGuard, /CHANGES entry for \$\{VERSION\} is empty/);