diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 5afe04b7..14b64ccb 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -39,6 +39,15 @@ In-scope components include: - client-side settings/runtime scripts - import/export and backup/restore flows - release packaging and update metadata +- archive extraction, durable storage, and process-execution boundaries +- privacy sanitization and browser-side injection defenses + +## Package Verification + +The Unraid plugin manifest publishes SHA-256 digests for shipped archives. Stable +GitHub releases also publish GitHub artifact attestations for build provenance and +the CycloneDX SBOM. Verification steps and the trust model are documented in +[`docs/security/PACKAGE_TRUST.md`](../docs/security/PACKAGE_TRUST.md). Out-of-scope: diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 3852bd30..aefcab81 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -8,6 +8,8 @@ on: permissions: contents: write + id-token: write + attestations: write concurrency: group: folderview-plus-release @@ -148,6 +150,21 @@ jobs: chmod +x scripts/remote_publish_guard.sh bash scripts/remote_publish_guard.sh + - name: Attest release archive provenance + if: steps.release_scope.outputs.should_publish == '1' + uses: actions/attest@36051bcae73b7c2a8a6945a48cbf80953c6baa35 # v4.2.0 + with: + subject-path: | + ${{ steps.release_meta.outputs.archive }} + ${{ steps.release_meta.outputs.checksum }} + + - name: Attest release archive SBOM + if: steps.release_scope.outputs.should_publish == '1' + uses: actions/attest@36051bcae73b7c2a8a6945a48cbf80953c6baa35 # v4.2.0 + with: + subject-path: ${{ steps.release_meta.outputs.archive }} + sbom-path: docs/sbom.cdx.json + - name: Ensure GitHub CLI if: steps.release_scope.outputs.should_publish == '1' run: | diff --git a/archive/folderview.plus-2026.07.23.12.txz b/archive/folderview.plus-2026.07.23.12.txz deleted file mode 100644 index fb25499c..00000000 Binary files a/archive/folderview.plus-2026.07.23.12.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.07.23.12.txz.sha256 b/archive/folderview.plus-2026.07.23.12.txz.sha256 deleted file mode 100644 index f3806a57..00000000 --- a/archive/folderview.plus-2026.07.23.12.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -2808ce845c739dee29162a3f2aa0af98adbaee2213fd2da94bbe832a80f857cf folderview.plus-2026.07.23.12.txz diff --git a/archive/folderview.plus-2026.07.28.04.txz b/archive/folderview.plus-2026.07.28.04.txz new file mode 100644 index 00000000..13549a9e Binary files /dev/null and b/archive/folderview.plus-2026.07.28.04.txz differ diff --git a/archive/folderview.plus-2026.07.28.04.txz.sha256 b/archive/folderview.plus-2026.07.28.04.txz.sha256 new file mode 100644 index 00000000..ffa9dfa4 --- /dev/null +++ b/archive/folderview.plus-2026.07.28.04.txz.sha256 @@ -0,0 +1 @@ +0c337e163cb23c7ebffc72d89d026c49ed70328706f4b22814eba18ecae4a4a2 folderview.plus-2026.07.28.04.txz diff --git a/docs/README.md b/docs/README.md index 95cecce9..363f6149 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,8 @@ This folder holds project documentation that does not need to stay at the reposi - [TRANSLATING.md](TRANSLATING.md) - [TRANSLATION_PLATFORM.md](TRANSLATION_PLATFORM.md) - [TROUBLESHOOTING.md](TROUBLESHOOTING.md) +- [Content Security Policy readiness](security/CSP_READINESS.md) +- [Package trust and provenance](security/PACKAGE_TRUST.md) ## Architecture diff --git a/docs/releases/2026.07.28.04.md b/docs/releases/2026.07.28.04.md new file mode 100644 index 00000000..e4066d74 --- /dev/null +++ b/docs/releases/2026.07.28.04.md @@ -0,0 +1,48 @@ +# FolderView Plus 2026.07.28.04 + +This security update strengthens archive handling, operating-system boundaries, +browser injection defenses, durable storage, managed themes, and release +provenance without removing compatibility fallbacks required by Unraid. + +## Archive and filesystem security + +- Security: Preflight icon asset-pack archives before extraction with strict path, + file-type, entry-count, nesting-depth, per-file-size, and total-size limits. +- Security: Reject archive links, special files, traversal paths, unexpected + top-level content, and post-extraction filesystem objects. +- Security: Use private temporary staging, restrictive permissions, protected + cleanup, and atomic activation for verified icon packs. +- Security: Constrain durable writes to approved plugin and Docker storage roots, + reject symbolic-link components, and create atomic temporary files with mode + `0600`. +- Security: Harden custom-icon upload staging and reject symbolic-link source, + destination, rename, and deletion paths. + +## Process and browser security + +- Security: Replace direct shell execution with one allowlisted, shell-free + `proc_open` boundary for Docker, VM, and Tailscale operations. +- Security: Validate runtime object names and actions, use fixed executable + candidates and argument arrays, cap output, and terminate timed-out processes. +- Security: Replace plugin-owned inline event handlers with a delegated, + declarative event bridge that uses an explicit function allowlist and restricted + argument grammar without `eval` or the `Function` constructor. +- Security: Add a deterministic CSP-readiness report and CI guard while retaining + report-only guidance because FolderView Plus shares its document with Unraid and + other plugins. + +## Theme and release supply-chain security + +- Security: Reject executable legacy CSS, remote imports, external network URLs, + and HTML data URLs from managed or custom themes before persistence and render. +- Security: Publish SHA-256 package and icon-pack digests in the Unraid manifest + while retaining MD5 only for compatibility. +- Security: Validate package SHA-256 sidecars and embedded build metadata during + release and installation checks. +- Security: Publish commit-pinned GitHub OIDC provenance and CycloneDX SBOM + attestations for stable release archives. +- Docs: Document package verification, attestation verification, CSP constraints, + and the expanded vulnerability-reporting scope. +- Test: Add regression contracts for archive limits, shell-free argument handling, + durable-path containment, declarative event bindings, CSP readiness, package + checksums, provenance, and SBOM attestations. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 17eac97b..25a72148 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.02", + "version": "2026.07.28.04", "properties": [ { "name": "folderview-plus:runtime-dependencies", @@ -24,6 +24,19 @@ } }, "components": [ + { + "type": "application", + "name": "actions/attest", + "version": "36051bcae73b7c2a8a6945a48cbf80953c6baa35", + "scope": "optional", + "purl": "pkg:github/actions/attest@36051bcae73b7c2a8a6945a48cbf80953c6baa35", + "properties": [ + { + "name": "folderview-plus:usage", + "value": "build-only-github-action" + } + ] + }, { "type": "application", "name": "actions/cache", diff --git a/docs/security/CSP_READINESS.md b/docs/security/CSP_READINESS.md new file mode 100644 index 00000000..dffe6545 --- /dev/null +++ b/docs/security/CSP_READINESS.md @@ -0,0 +1,30 @@ +# Content Security Policy readiness + +FolderView Plus runs inside web documents owned by the Unraid webGUI. It cannot safely +set an enforced page-wide Content Security Policy by itself: that header would also +govern Unraid and every other plugin loaded into the same document. + +The repository therefore uses a staged, report-only approach: + +1. Plugin-owned inline event attributes are prohibited. UI actions use + `folderviewplus.csp-events.js`, which accepts only an explicit function allowlist + and a restricted argument grammar. It does not use `eval()` or `Function`. +2. Managed theme CSS rejects executable legacy CSS, `@import`, external network + URLs, and HTML data URLs before storage or rendering. +3. `scripts/csp_readiness_guard.mjs` inventories remaining inline scripts, styles, + dynamic script creation, and HTML string sinks. Its deterministic output is + stored in `docs/security/csp-readiness.json`. +4. CI rejects any return of inline handlers, `eval()`, or the `Function` + constructor, and rejects a stale readiness report. + +Regenerate and verify the report with: + +```bash +node scripts/csp_readiness_guard.mjs --write +node scripts/csp_readiness_guard.mjs +``` + +The report includes a candidate report-only policy. It is documentation for +host-level testing, not a header emitted by the plugin. Enforced CSP should only be +considered after Unraid exposes a plugin-safe nonce/hash mechanism or after a +host-level report confirms that Unraid and installed peer plugins remain compatible. diff --git a/docs/security/PACKAGE_TRUST.md b/docs/security/PACKAGE_TRUST.md new file mode 100644 index 00000000..f8dbae0e --- /dev/null +++ b/docs/security/PACKAGE_TRUST.md @@ -0,0 +1,41 @@ +# Package trust and provenance + +FolderView Plus uses layered verification so current Unraid releases remain +compatible while stronger supply-chain evidence is available to administrators. + +## Installation verification + +- The plugin manifest supplies both MD5 and SHA-256 for the core package and icon + asset pack. MD5 remains only for compatibility with older plugin-manager paths; + SHA-256 is the security boundary. +- `scripts/release_guard.sh` requires the manifest SHA-256, checked-in checksum + 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. +- 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. +- The installed version is written only after package configuration and icon-pack + activation complete. Existing install reporting identifies upgrades, reinstalls, + downgrades, failures, and the prior version. + +## GitHub attestations + +Stable releases generated by `release-on-main.yml` publish: + +- a signed provenance attestation for the `.txz` archive and `.sha256` sidecar; +- a signed CycloneDX SBOM attestation binding `docs/sbom.cdx.json` to the archive. + +The workflow uses GitHub OIDC and the official, commit-pinned `actions/attest` +action. It does not use a stored signing key. + +After downloading a stable release archive, verify it online with: + +```bash +gh attestation verify folderview.plus-YYYY.MM.DD.UU.txz \ + --repo alexphillips-dev/FolderView-Plus +``` + +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. diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json new file mode 100644 index 00000000..941b01ff --- /dev/null +++ b/docs/security/csp-readiness.json @@ -0,0 +1,552 @@ +{ + "schemaVersion": 1, + "mode": "report-only-shared-unraid-document", + "scope": { + "scannedRoot": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus", + "scannedFiles": 149, + "exclusions": [ + "third-party icon asset pack", + "localization catalogs", + "vendored scripts", + "minified scripts" + ] + }, + "enforcement": { + "inlineEventAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "reason": "FolderView Plus shares Unraid host documents, so a plugin-scoped enforced CSP header could break host or peer-plugin code." + }, + "totals": { + "inlineEventAttributes": 0, + "inlineScriptBlocks": 22, + "externalScriptTags": 111, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 207, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 5, + "htmlStringSinks": 46 + }, + "readiness": { + "enforceableNow": false, + "blockers": [ + "22 host-page inline script blocks remain.", + "207 inline style attributes remain." + ], + "completed": [ + "Plugin-owned event attributes use an allowlisted declarative event bridge.", + "No eval() or Function constructor is permitted in first-party runtime source.", + "Managed theme CSS rejects executable rules, imports, and external network URLs." + ], + "next": [ + "Move remaining page bootstrap blocks into external versioned assets.", + "Replace remaining style attributes/blocks with scoped classes or nonce-compatible host primitives.", + "Capture a report-only policy at the Unraid host layer before considering enforcement." + ] + }, + "recommendedReportOnlyPolicy": [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "connect-src 'self' https://api.github.com https://raw.githubusercontent.com", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'self'" + ], + "filesWithDebt": [ + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 4, + "externalScriptTags": 18, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 61, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 1, + "htmlStringSinks": 2 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 5, + "externalScriptTags": 3, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 18, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Dashboard.page", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 2, + "externalScriptTags": 27, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 5, + "externalScriptTags": 40, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 2, + "externalScriptTags": 21, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/custom.php", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 1, + "externalScriptTags": 2, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 1, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/dashboard.advanced-preview.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/dashboard.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 3, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/dashboard.layout-quickrail.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 3, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/dashboard.visual-diagnostics.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 2, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.bootstrap.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 1, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 18, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 6 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 1 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 23, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.command-view.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 3 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 9, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 3, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 3, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 1 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 5, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 5 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.hierarchy.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.icons.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.parent-picker.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 1 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.preview.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 5, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.rules.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 2 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 18, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.activity-diagnostics.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 2 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.bulk-assignment.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 5 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.diagnostics-view.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 2 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.fatal-banner.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 2 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-editor.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 1 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 3, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 3 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.settings-loader.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 1, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.starter-templates.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.theme-workspace.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 6 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 3 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.wizard.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 4, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 1 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.asset-loader.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 0, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 1, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.column-layout.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 2, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 6, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.runtime.lifecycle.js", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 0, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 1, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + }, + { + "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php", + "inlineEventAttributes": 0, + "inlineScriptBlocks": 3, + "externalScriptTags": 0, + "inlineStyleBlocks": 0, + "inlineStyleAttributes": 13, + "evalCalls": 0, + "functionConstructors": 0, + "dynamicScriptCreation": 0, + "htmlStringSinks": 0 + } + ] +} diff --git a/folderview.plus.plg b/folderview.plus.plg index c59b1415..de246681 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,8 +6,9 @@ - - + + + @@ -17,6 +18,50 @@ +###2026.07.28.04 +# FolderView Plus 2026.07.28.04 +This security update strengthens archive handling, operating-system boundaries, +browser injection defenses, durable storage, managed themes, and release +provenance without removing compatibility fallbacks required by Unraid. +## Archive and filesystem security +- Security: Preflight icon asset-pack archives before extraction with strict path, + file-type, entry-count, nesting-depth, per-file-size, and total-size limits. +- Security: Reject archive links, special files, traversal paths, unexpected + top-level content, and post-extraction filesystem objects. +- Security: Use private temporary staging, restrictive permissions, protected + cleanup, and atomic activation for verified icon packs. +- Security: Constrain durable writes to approved plugin and Docker storage roots, + reject symbolic-link components, and create atomic temporary files with mode + `0600`. +- Security: Harden custom-icon upload staging and reject symbolic-link source, + destination, rename, and deletion paths. +## Process and browser security +- Security: Replace direct shell execution with one allowlisted, shell-free + `proc_open` boundary for Docker, VM, and Tailscale operations. +- Security: Validate runtime object names and actions, use fixed executable + candidates and argument arrays, cap output, and terminate timed-out processes. +- Security: Replace plugin-owned inline event handlers with a delegated, + declarative event bridge that uses an explicit function allowlist and restricted + argument grammar without `eval` or the `Function` constructor. +- Security: Add a deterministic CSP-readiness report and CI guard while retaining + report-only guidance because FolderView Plus shares its document with Unraid and + other plugins. +## Theme and release supply-chain security +- Security: Reject executable legacy CSS, remote imports, external network URLs, + and HTML data URLs from managed or custom themes before persistence and render. +- Security: Publish SHA-256 package and icon-pack digests in the Unraid manifest + while retaining MD5 only for compatibility. +- Security: Validate package SHA-256 sidecars and embedded build metadata during + release and installation checks. +- Security: Publish commit-pinned GitHub OIDC provenance and CycloneDX SBOM + attestations for stable release archives. +- Docs: Document package verification, attestation verification, CSP constraints, + and the expanded vulnerability-reporting scope. +- Test: Add regression contracts for archive limits, shell-free argument handling, + durable-path containment, declarative event bindings, CSP readiness, package + checksums, provenance, and SBOM attestations. + + ###2026.07.28.02 # FolderView Plus 2026.07.28.02 - Security: Build third-party icon results with DOM nodes and text properties instead of appending an HTML string. @@ -7915,11 +7960,13 @@ This dev-channel synchronization packages the cross-browser Diagnostics correcti &iconPackURL; &iconPackMd5; + &iconPackSha256; https://raw.githubusercontent.com/&github;/dev/archive/&name;-&version;.txz &md5; + &sha256; diff --git a/package.json b/package.json index 83cfc872..fe03b09f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "test:coverage": "c8 --check-coverage --lines 70 --functions 65 --branches 60 --statements 70 --include=src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils.js --include=src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.view-settings.js --include=src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.diagnostics-view-model.js --include=src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.container-model.js --include=src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.layout-geometry.js --include=scripts/lib/support_bundle_compare.mjs node --test tests/folderviewplus-utils.test.mjs tests/view-settings-behavior.test.mjs tests/diagnostics-view-model.test.mjs tests/docker-runtime-diagnostics-geometry.test.mjs tests/unraid-docker-future-compatibility.test.mjs tests/support-bundle-comparator.test.mjs", "sbom": "node scripts/generate_sbom.mjs", "sbom:check": "node scripts/generate_sbom.mjs --check", + "csp:check": "node scripts/csp_readiness_guard.mjs", "compare:support-bundles": "node scripts/compare_support_bundles.mjs" }, "devDependencies": { diff --git a/pkg_build.sh b/pkg_build.sh index 837f6934..3c489fc9 100644 --- a/pkg_build.sh +++ b/pkg_build.sh @@ -668,9 +668,14 @@ sha256=$(sha256sum "$filename" | awk '{print $1}') sha256_file="${filename}.sha256" printf '%s %s\n' "$sha256" "$(basename "$filename")" > "$sha256_file" -# Update version and md5 in plg file +# Update version and package digests in plg file sed -i "s///" "$plgfile" sed -i "s///" "$plgfile" +if grep -q '^//" "$plgfile" +else + sed -i "/^" "$plgfile" +fi # Keep CA template date aligned with the release version date. if [ -n "$xml_date" ]; then diff --git a/scripts/csp_readiness_guard.mjs b/scripts/csp_readiness_guard.mjs new file mode 100644 index 00000000..69db6d8c --- /dev/null +++ b/scripts/csp_readiness_guard.mjs @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoot = path.join( + repoRoot, + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus' +); +const reportPath = path.join(repoRoot, 'docs/security/csp-readiness.json'); +const writeMode = process.argv.includes('--write'); + +const normalize = (value) => value.replaceAll('\\', '/'); +const excluded = (absolutePath) => { + const relative = normalize(path.relative(sourceRoot, absolutePath)); + return relative.startsWith('images/third-party-icons/') + || relative.startsWith('langs/') + || relative.startsWith('scripts/include/') + || relative.endsWith('.min.js'); +}; +const files = []; +const walk = (directory) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (excluded(absolute)) continue; + if (entry.isDirectory()) { + walk(absolute); + } else if (/\.(?:js|page|php)$/i.test(entry.name)) { + files.push(absolute); + } + } +}; +walk(sourceRoot); +files.sort(); + +const patterns = { + inlineEventAttributes: /\son(?:click|change|input|keydown|submit|error)\s*=\s*['"]/gi, + inlineScriptBlocks: /]*\bsrc\s*=)[^>]*>/gi, + externalScriptTags: /]*\bsrc\s*=/gi, + inlineStyleBlocks: /]*>/gi, + inlineStyleAttributes: /\sstyle\s*=/gi, + evalCalls: /\beval\s*\(/g, + functionConstructors: /\bnew\s+Function\s*\(/g, + dynamicScriptCreation: /createElement\s*\(\s*['"]script['"]\s*\)/g, + htmlStringSinks: /\.(?:innerHTML|outerHTML)\s*=|insertAdjacentHTML\s*\(/g +}; +const totals = Object.fromEntries(Object.keys(patterns).map((key) => [key, 0])); +const perFile = []; +for (const absolute of files) { + const source = fs.readFileSync(absolute, 'utf8'); + const metrics = {}; + let hasDebt = false; + for (const [key, pattern] of Object.entries(patterns)) { + pattern.lastIndex = 0; + const count = [...source.matchAll(pattern)].length; + metrics[key] = count; + totals[key] += count; + if (count > 0) hasDebt = true; + } + if (hasDebt) { + perFile.push({ + path: normalize(path.relative(repoRoot, absolute)), + ...metrics + }); + } +} + +const blockers = [ + totals.inlineEventAttributes > 0 ? `${totals.inlineEventAttributes} inline event attributes remain.` : '', + totals.inlineScriptBlocks > 0 ? `${totals.inlineScriptBlocks} host-page inline script blocks remain.` : '', + totals.inlineStyleBlocks > 0 ? `${totals.inlineStyleBlocks} inline style blocks remain.` : '', + totals.inlineStyleAttributes > 0 ? `${totals.inlineStyleAttributes} inline style attributes remain.` : '' +].filter(Boolean); +const report = { + schemaVersion: 1, + mode: 'report-only-shared-unraid-document', + scope: { + scannedRoot: normalize(path.relative(repoRoot, sourceRoot)), + scannedFiles: files.length, + exclusions: [ + 'third-party icon asset pack', + 'localization catalogs', + 'vendored scripts', + 'minified scripts' + ] + }, + enforcement: { + inlineEventAttributes: 0, + evalCalls: 0, + functionConstructors: 0, + reason: 'FolderView Plus shares Unraid host documents, so a plugin-scoped enforced CSP header could break host or peer-plugin code.' + }, + totals, + readiness: { + enforceableNow: blockers.length === 0, + blockers, + completed: [ + 'Plugin-owned event attributes use an allowlisted declarative event bridge.', + 'No eval() or Function constructor is permitted in first-party runtime source.', + 'Managed theme CSS rejects executable rules, imports, and external network URLs.' + ], + next: [ + 'Move remaining page bootstrap blocks into external versioned assets.', + 'Replace remaining style attributes/blocks with scoped classes or nonce-compatible host primitives.', + 'Capture a report-only policy at the Unraid host layer before considering enforcement.' + ] + }, + recommendedReportOnlyPolicy: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "connect-src 'self' https://api.github.com https://raw.githubusercontent.com", + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'self'" + ], + filesWithDebt: perFile +}; +const serialized = `${JSON.stringify(report, null, 2)}\n`; + +for (const [metric, expected] of Object.entries(report.enforcement)) { + if (typeof expected !== 'number') continue; + if (totals[metric] !== expected) { + console.error(`ERROR: CSP readiness metric ${metric} is ${totals[metric]} (expected ${expected}).`); + process.exit(1); + } +} + +if (writeMode) { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync(reportPath, serialized, 'utf8'); + console.log(`Wrote ${normalize(path.relative(repoRoot, reportPath))}.`); +} else { + if (!fs.existsSync(reportPath) || fs.readFileSync(reportPath, 'utf8') !== serialized) { + console.error('ERROR: CSP readiness report is stale. Run: node scripts/csp_readiness_guard.mjs --write'); + process.exit(1); + } + console.log( + `CSP readiness guard passed: ${files.length} files, ${totals.inlineEventAttributes} inline events, ` + + `${totals.inlineScriptBlocks} inline scripts, ${totals.inlineStyleAttributes} style attributes.` + ); +} diff --git a/scripts/fixture_browser_tests.mjs b/scripts/fixture_browser_tests.mjs index 1c06c6bf..ac89ee20 100644 --- a/scripts/fixture_browser_tests.mjs +++ b/scripts/fixture_browser_tests.mjs @@ -1396,7 +1396,7 @@ test('Mobile reorder persists click state and isolates Docker and VM controls', root.classList.add('fv-mobile-compact'); document.body.classList.add('fv-mobile-compact'); root.insertAdjacentHTML('beforeend', ` - +
DockerActions
VMActions
`); diff --git a/scripts/include_order_guard.sh b/scripts/include_order_guard.sh index a6eedc7c..438338d7 100644 --- a/scripts/include_order_guard.sh +++ b/scripts/include_order_guard.sh @@ -47,6 +47,7 @@ const source = fs.readFileSync(pageFile, 'utf8'); const includes = [...source.matchAll(/folderviewplus(?:\.[a-z-]+)*\.js/g)].map((match) => match[0]); const expectedOrder = [ + 'folderviewplus.csp-events.js', 'folderviewplus.fatal-banner.js', 'folderviewplus.utils.js', 'folderviewplus.ui.js', diff --git a/scripts/install_smoke.sh b/scripts/install_smoke.sh index 496bbd0b..19a09ff8 100644 --- a/scripts/install_smoke.sh +++ b/scripts/install_smoke.sh @@ -69,6 +69,8 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.runtime-snapshot.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.wizard.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.import.js" + "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js" + "./usr/local/emhttp/plugins/folderview.plus/scripts/archive_preflight.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/install_icon_asset_pack.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/install_report.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" @@ -78,6 +80,8 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.icons.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folder.js" "./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.diagnostics.php" "./usr/local/emhttp/plugins/folderview.plus/server/apply_folder_settings.php" "./usr/local/emhttp/plugins/folderview.plus/server/read.php" @@ -146,6 +150,8 @@ REQUIRED_FILES=( "scripts/folderviewplus.runtime-snapshot.js" "scripts/folderviewplus.wizard.js" "scripts/folderviewplus.import.js" + "scripts/folderviewplus.csp-events.js" + "scripts/archive_preflight.sh" "scripts/install_icon_asset_pack.sh" "scripts/install_report.sh" "scripts/docker.runtime.hierarchy.js" @@ -160,6 +166,8 @@ REQUIRED_FILES=( "styles/folderviewplus.css" "styles/folder.css" "server/lib.php" + "server/lib.process.php" + "server/lib.filesystem-security.php" "server/lib.diagnostics.php" "server/apply_folder_settings.php" "server/read.php" diff --git a/scripts/release_guard.sh b/scripts/release_guard.sh index 7cb66caa..e06fb14a 100644 --- a/scripts/release_guard.sh +++ b/scripts/release_guard.sh @@ -15,6 +15,7 @@ ICON_ASSET_PACK_GUARD="${ROOT_DIR}/scripts/icon_asset_pack_guard.sh" ARCHIVE_DIR="${FVPLUS_ARCHIVE_DIR:-${ROOT_DIR}/archive}" MAX_ARCHIVE_BYTES="${FVPLUS_MAX_ARCHIVE_BYTES:-52428800}" # 50 MiB default ceiling MAX_ARCHIVE_FILE_COUNT="${FVPLUS_MAX_ARCHIVE_FILE_COUNT:-10000}" +NODE_BIN="$(fvplus::resolve_platform_command node)" resolve_php_bin() { fvplus::resolve_platform_command php @@ -60,6 +61,7 @@ fi VERSION="$(sed -n 's/^&2 exit 1 fi +if [[ ! "${SHA256_ENTITY}" =~ ^[a-f0-9]{64}$ ]]; then + echo "ERROR: Could not parse a valid sha256 entity from folderview.plus.plg" >&2 + exit 1 +fi if [[ ! -x "${ICON_ASSET_PACK_GUARD}" && ! -f "${ICON_ASSET_PACK_GUARD}" ]]; then echo "ERROR: Missing icon asset-pack guard: ${ICON_ASSET_PACK_GUARD}" >&2 exit 1 @@ -392,6 +398,7 @@ UNEXPECTED_ARCHIVE_FILES="$( printf '%s\n' "${ARCHIVE_FILES_ONLY}" \ | grep -Evi "\.(${ALLOWED_ARCHIVE_EXTENSIONS})$" \ | grep -Fvx 'install/slack-desc' \ + | grep -Fvx 'usr/local/emhttp/plugins/folderview.plus/scripts/archive_preflight.sh' \ | grep -Fvx 'usr/local/emhttp/plugins/folderview.plus/scripts/install_icon_asset_pack.sh' \ | grep -Fvx 'usr/local/emhttp/plugins/folderview.plus/scripts/install_report.sh' \ || true @@ -404,7 +411,10 @@ fi REQUIRED_ARCHIVE_PATHS=( "./install/slack-desc" + "./usr/local/emhttp/plugins/folderview.plus/build-metadata.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" "./usr/local/emhttp/plugins/folderview.plus/scripts/folder.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js" @@ -440,6 +450,8 @@ REQUIRED_ARCHIVE_PATHS=( "./usr/local/emhttp/plugins/folderview.plus/Folder.page" "./usr/local/emhttp/plugins/folderview.plus/FolderViewPlus.page" "./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.diagnostics.php" "./usr/local/emhttp/plugins/folderview.plus/server/lib.runtime-snapshot.php" "./usr/local/emhttp/plugins/folderview.plus/server/runtime_snapshot.php" @@ -455,6 +467,29 @@ for required_path in "${REQUIRED_ARCHIVE_PATHS[@]}"; do fi done +ARCHIVE_BUILD_METADATA_PATH="./usr/local/emhttp/plugins/folderview.plus/build-metadata.json" +BUILD_METADATA_JSON="$(tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_BUILD_METADATA_PATH}")" +"${NODE_BIN}" - "${BUILD_METADATA_JSON}" "${VERSION}" "${EXPECTED_PLUGIN_BRANCH}" <<'NODE' +const metadata = JSON.parse(process.argv[2] || '{}'); +const version = String(process.argv[3] || ''); +const branch = String(process.argv[4] || ''); +const expectedArchiveUrl = `https://raw.githubusercontent.com/alexphillips-dev/FolderView-Plus/${branch}/archive/folderview.plus-${version}.txz`; +const fail = (message) => { + console.error(`ERROR: Packaged build metadata ${message}`); + process.exit(1); +}; +if (metadata.packageVersion !== version) fail('version does not match the manifest.'); +if (metadata.sourceBranch !== branch) fail('branch does not match the release channel.'); +if (metadata.archiveUrl !== expectedArchiveUrl) fail('archive URL does not match version and channel.'); +if (!/^[a-f0-9]{64}$/.test(String(metadata.sourceContentSha256 || ''))) fail('source content digest is invalid.'); +if (metadata.sourceSnapshotMode !== 'content' || metadata.sourceCommitExact !== false) { + fail('must use the reproducible content-addressed snapshot contract.'); +} +for (const field of ['sourceCommitSha', 'headCommitSha', 'sourceTreeSha']) { + if (String(metadata[field] || '') !== '') fail(`${field} must remain empty to avoid self-referential archives.`); +} +NODE + if ! grep -q 'click\.fvsectionheader' "${SOURCE_SETTINGS_JS}"; then echo "ERROR: Source folderviewplus.js is missing mobile section-toggle header binding." >&2 exit 1 @@ -1104,8 +1139,19 @@ if [[ "${MD5_ENTITY}" != "${MD5_CALC}" ]]; then echo "ERROR: md5 entity mismatch. plg=${MD5_ENTITY}, archive=${MD5_CALC}" >&2 exit 1 fi +SHA256_CALC="$(sha256sum "${ARCHIVE_FILE}" | awk '{print $1}')" +if [[ "${SHA256_ENTITY}" != "${SHA256_CALC}" ]]; then + echo "ERROR: sha256 entity mismatch. plg=${SHA256_ENTITY}, archive=${SHA256_CALC}" >&2 + exit 1 +fi +EXPECTED_CHECKSUM_LINE="${SHA256_CALC} ${ARCHIVE_FILE##*/}" +if [[ "$(tr -d '\r' < "${ARCHIVE_FILE}.sha256")" != "${EXPECTED_CHECKSUM_LINE}" ]]; then + echo "ERROR: package checksum sidecar does not exactly match the archive name and SHA-256." >&2 + exit 1 +fi echo "Release guard checks passed:" echo " version: ${VERSION}" echo " archive: ${ARCHIVE_FILE##*/}" echo " md5: ${MD5_CALC}" +echo " sha256: ${SHA256_CALC}" diff --git a/scripts/run_ci_suite.sh b/scripts/run_ci_suite.sh index 7b67292b..64376293 100644 --- a/scripts/run_ci_suite.sh +++ b/scripts/run_ci_suite.sh @@ -206,6 +206,7 @@ run_lane() { run_timed_step deprecation-contract "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/deprecation_guard.mjs")" run_timed_step architecture-contracts "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/architecture_contract_guard.mjs")" run_timed_step sbom "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/generate_sbom.mjs")" --check + run_timed_step csp-readiness "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/csp_readiness_guard.mjs")" run_timed_step action-pins "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/action_pin_guard.mjs")" run_timed_step shellcheck lint_shell_scripts run_timed_step javascript-syntax lint_javascript_syntax diff --git a/scripts/workflow_self_check.sh b/scripts/workflow_self_check.sh index 98fe86b5..4b7ba5f2 100644 --- a/scripts/workflow_self_check.sh +++ b/scripts/workflow_self_check.sh @@ -36,6 +36,7 @@ for (const relativePath of [ 'scripts/run_ci_suite.sh', 'scripts/actionlint_guard.sh', 'scripts/classify_ci_changes.mjs', + 'scripts/csp_readiness_guard.mjs', 'scripts/fixture_browser_tests.sh', 'scripts/fixture_browser_tests.mjs', 'scripts/runtime_performance_benchmarks.sh', @@ -153,6 +154,15 @@ for (const [name, workflow] of [ if (!/bash scripts\/build_release_notes\.sh/.test(releaseOnMainWorkflow)) { fail('Release On Main workflow must build release notes via scripts/build_release_notes.sh.'); } +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 ((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) || + !/sbom-path:\s*docs\/sbom\.cdx\.json/.test(releaseOnMainWorkflow)) { + fail('Release On Main must publish commit-pinned provenance and SBOM attestations for the release archive.'); +} if (!/FVPLUS_BROWSER_SMOKE_REQUIRED:\s*'1'/.test(releaseOnMainWorkflow)) { fail('Release On Main must fail closed unless live browser smoke coverage is configured.'); } @@ -233,6 +243,9 @@ for (const [workflowName, workflow, jobNames] of [ const runCiSuite = read('scripts/run_ci_suite.sh'); const actionlintGuard = read('scripts/actionlint_guard.sh'); +if (!/run_timed_step csp-readiness/.test(runCiSuite)) { + fail('The lint lane must enforce the deterministic CSP readiness report.'); +} if (!/run_timed_step actionlint bash scripts\/actionlint_guard\.sh/.test(runCiSuite)) { fail('Workflow and full guard lanes must run the pinned actionlint guard.'); } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page index ca38a406..096f8350 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page @@ -82,6 +82,7 @@ try { ]; } ?> +
-
+ @@ -1290,13 +1291,13 @@ try {
- +
- - - - + + + +