diff --git a/archive/folderview.plus-2026.08.11.12.txz b/archive/folderview.plus-2026.08.11.12.txz deleted file mode 100644 index e72d3da8..00000000 Binary files a/archive/folderview.plus-2026.08.11.12.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.11.12.txz.sha256 b/archive/folderview.plus-2026.08.11.12.txz.sha256 deleted file mode 100644 index 2ce56610..00000000 --- a/archive/folderview.plus-2026.08.11.12.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -60324c479ebdbbcaa37cce5ddb62381fd4ab7e086c50869f5b8a7a26767f4324 folderview.plus-2026.08.11.12.txz diff --git a/archive/folderview.plus-2026.08.19.07.txz b/archive/folderview.plus-2026.08.19.07.txz deleted file mode 100644 index 245e4e37..00000000 Binary files a/archive/folderview.plus-2026.08.19.07.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.19.07.txz.sha256 b/archive/folderview.plus-2026.08.19.07.txz.sha256 deleted file mode 100644 index 02832086..00000000 --- a/archive/folderview.plus-2026.08.19.07.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f64322b8fda9127b3c192fea5c16d375e6965c4cec7481818daf5517dfdeab1f folderview.plus-2026.08.19.07.txz diff --git a/archive/folderview.plus-2026.08.20.01.txz b/archive/folderview.plus-2026.08.20.01.txz new file mode 100644 index 00000000..5c2c0514 Binary files /dev/null and b/archive/folderview.plus-2026.08.20.01.txz differ diff --git a/archive/folderview.plus-2026.08.20.01.txz.sha256 b/archive/folderview.plus-2026.08.20.01.txz.sha256 new file mode 100644 index 00000000..06919aef --- /dev/null +++ b/archive/folderview.plus-2026.08.20.01.txz.sha256 @@ -0,0 +1 @@ +66ef8fd9e6c7ff10de19560196e88ffe1c85e4e9434142aae2df17c8ef4dd6d1 folderview.plus-2026.08.20.01.txz diff --git a/archive/folderview.plus-2026.08.20.02.txz b/archive/folderview.plus-2026.08.20.02.txz new file mode 100644 index 00000000..c5748d4b Binary files /dev/null and b/archive/folderview.plus-2026.08.20.02.txz differ diff --git a/archive/folderview.plus-2026.08.20.02.txz.sha256 b/archive/folderview.plus-2026.08.20.02.txz.sha256 new file mode 100644 index 00000000..6eb1830b --- /dev/null +++ b/archive/folderview.plus-2026.08.20.02.txz.sha256 @@ -0,0 +1 @@ +f0b2143536d393b8c641b9ac154795230aae5a3eb3ff319facbfc13bdb871b46 folderview.plus-2026.08.20.02.txz diff --git a/docs/README.md b/docs/README.md index 363f6149..4f616717 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ This folder holds project documentation that does not need to stay at the reposi - [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - [Content Security Policy readiness](security/CSP_READINESS.md) - [Package trust and provenance](security/PACKAGE_TRUST.md) +- [Request security and reverse-proxy requirements](security/REQUEST_SECURITY.md) ## Architecture diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d8906b45..1c785842 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -48,6 +48,41 @@ https://raw.githubusercontent.com/alexphillips-dev/FolderView-Plus//fold Do not edit `/boot/config/plugins/folderview.plus/version` to force an update. See [Installation and Upgrades](INSTALLATION_AND_UPGRADES.md#not-reinstalling-same-version). +### Reverse Proxy Actions Say `Blocked by request guard` + +If pages load through SWAG or another TLS-terminating reverse proxy but create, +save, backup, restore, or other protected actions return HTTP 403, first test +the same action through the normal Unraid address. If direct access works, keep +the request guard enabled and verify the proxy authority headers. + +For SWAG, include its standard proxy configuration in the Unraid location: + +```nginx +include /config/nginx/proxy.conf; +proxy_pass http://:; +``` + +The resulting request must provide one value for each of these headers: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Port $server_port; +``` + +Do not add `X-FV-Request` in the proxy, remove `Origin` or `Referer`, or disable +the FolderView Plus guard. The plugin request client supplies its own request +marker. FolderView Plus rejects partial, repeated, malformed, or conflicting +forwarded values, including a forwarded hostname that differs from `Host`. + +After changing SWAG, reload its Nginx configuration, hard-refresh the Unraid +page, and retry one protected action. If it still fails, export a sanitized +support bundle through the failing proxy URL. The +`system.requestSecurity` section reports privacy-safe origin and forwarded +authority reason codes without recording the hostname, IP address, or raw +header values. See [Request security and abuse controls](security/REQUEST_SECURITY.md#reverse-proxies). + ### Import Fails Validation Make sure Docker exports are imported into Docker and VM exports into VMs. Re-export with the latest plugin version if the file came from older tooling. diff --git a/docs/releases/2026.08.19.07.md b/docs/releases/2026.08.19.07.md deleted file mode 100644 index b79e871a..00000000 --- a/docs/releases/2026.08.19.07.md +++ /dev/null @@ -1,13 +0,0 @@ -- Fix: Eliminate the 5–7 second Docker-page refresh loop caused by API container-identity differences recursively scheduling native list reloads after FolderView renders. -- Fix: Prevent FolderView-generated images with blank or unreachable icon URLs from repeatedly requesting the failed source during subsequent FolderView renders, while immediately using the local fallback for blank values. -- Fix: Restore Docker folder boxes created through the Add Folder wizard when Unraid identifies container rows by short container IDs instead of container names. -- Fix: Keep populated folders visible when Hide empty folders removes earlier placeholders and later populated folders have high saved-order positions. -- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, use GraphQL only to enrich containers already present in the host runtime map, and preserve native rows during Docker busy-poll cycles. -- Reliability: Resolve Docker row identities through the normalized runtime model, retain deterministic first-folder ownership for overlapping rules, insert folder shells relative to connected host rows, and leave member rows under Unraid ownership if a shell cannot be attached safely. -- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling to avoid unnecessary request bundles, folder grouping, preview hydration, and DOM replacement. -- Diagnostics: Retain five bounded Docker-page session summaries with privacy-safe reload-source attribution, render and request counts, cadence, busy-cycle recovery, API-mismatch aggregates, and the `FVPLUS-DKR-REFRESH-001` healthy/suspected/confirmed verdict. -- Diagnostics: Count completed renders by monotonic generation, keep trace-health timestamps current, age browser-error evidence, and report aggregate host-row resolution, folder claims, missing members, hide-empty removals, and folder-shell insertion outcomes. -- Privacy: Keep runtime, refresh, API, icon, and folder-grouping diagnostics bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. -- Docs: Expand troubleshooting and Docker runtime/API architecture guidance for native refresh ownership, busy polling, identity mismatches, folder grouping, support-bundle interpretation, and safe escalation. -- Test: Add coordinator, native busy-indicator, double-reload, image fallback, short-ID grouping, high-position hide-empty, repeated-render, browser-fixture, support-bundle, and large-runtime performance regressions. -- Quality: Validate the complete stable promotion through strict lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. diff --git a/docs/releases/2026.08.20.01.md b/docs/releases/2026.08.20.01.md new file mode 100644 index 00000000..4fa86ac6 --- /dev/null +++ b/docs/releases/2026.08.20.01.md @@ -0,0 +1,5 @@ +- Fix: Allow protected FolderView Plus actions through a correctly configured TLS-terminating reverse proxy by validating the external `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` authority as one coherent tuple. +- Security: Keep strict POST, request-marker, install-token, one-time nonce, transaction, same-origin, and rate-limit controls unchanged; reject partial, repeated, malformed, spoofed-host, and conflicting-port forwarded headers. +- Diagnostics: Add privacy-safe request-security reason codes to diagnostics and support bundles without recording hostnames, addresses, ports, or raw forwarded-header values. +- Docs: Add SWAG reverse-proxy requirements, a troubleshooting flow for `Blocked by request guard`, and guidance not to inject `X-FV-Request` or remove browser origin headers at the proxy. +- Test: Cover direct HTTP/HTTPS, standard SWAG forwarding, nonstandard ports, IPv6, spoofed and malformed tuples, and independent Origin/Referer disagreement. diff --git a/docs/releases/2026.08.20.02.md b/docs/releases/2026.08.20.02.md new file mode 100644 index 00000000..89f7c024 --- /dev/null +++ b/docs/releases/2026.08.20.02.md @@ -0,0 +1,5 @@ +- Fix: Allow protected FolderView Plus actions through a correctly configured TLS-terminating reverse proxy by validating the external host, protocol, and port as one coherent authority. +- Security: Keep strict POST, request-marker, install-token, one-time nonce, transaction, same-origin, and rate-limit controls while rejecting partial, repeated, malformed, spoofed-host, or conflicting forwarded headers. +- Diagnostics: Add privacy-safe request-security reason codes to diagnostics and support bundles without recording hostnames, addresses, ports, or raw forwarded-header values. +- Docs: Document supported SWAG forwarding, recovery from `Blocked by request guard`, and unsafe proxy workarounds to avoid. +- Test: Cover direct HTTP/HTTPS, standard and nonstandard proxy ports, IPv6, malformed or spoofed forwarded values, and Origin/Referer disagreements. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 4d865597..5fcf9e3e 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -1,13 +1,13 @@ { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": "urn:uuid:7d763a96-545a-896b-b7a7-d64cb56bf965", + "serialNumber": "urn:uuid:fc08839a-71b1-8d83-9a91-89ef86f15baa", "version": 1, "metadata": { "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.08.19.07", + "version": "2026.08.20.02", "properties": [ { "name": "folderview-plus:runtime-dependencies", diff --git a/docs/security/REQUEST_SECURITY.md b/docs/security/REQUEST_SECURITY.md index b9886ee0..4e318e77 100644 --- a/docs/security/REQUEST_SECURITY.md +++ b/docs/security/REQUEST_SECURITY.md @@ -21,6 +21,34 @@ 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. +### Reverse proxies + +FolderView Plus supports a TLS-terminating reverse proxy without disabling its +same-origin guard. A proxied request can use its external authority only when +all of these headers are present as one coherent, single-valued tuple: + +- `Host` and `X-Forwarded-Host` contain the same browser-facing hostname; +- `X-Forwarded-Proto` is exactly `http` or `https`; +- `X-Forwarded-Port` is a valid port from 1 through 65535; and +- an explicit port in `X-Forwarded-Host`, when present, equals + `X-Forwarded-Port`. + +The request `Origin` and `Referer`, when supplied by the browser, must match +either the direct Unraid authority or that validated forwarded authority. +Partial, comma-separated, malformed, or conflicting forwarded headers are +ignored and the request fails closed when its origin does not match directly. +The forwarded headers never replace the request marker, install token, nonce, +transaction, POST, or rate-limit controls. + +The standard LinuxServer SWAG `proxy.conf` supplies the required host, +forwarded-host, forwarded-protocol, and forwarded-port headers. A custom +location must retain those settings. It does not need to inject +`X-FV-Request`; the FolderView Plus browser client supplies its own marker. + +Request-security diagnostics expose only bounded reason codes such as +`valid`, `partial`, `host-mismatch`, and `port-mismatch`. Hostnames, addresses, +and header values are not included in that diagnostic section. + 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 diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index 064015b8..6d1a7318 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": 233, + "scannedFiles": 234, "exclusions": [ "third-party icon asset pack", "vendored scripts", diff --git a/folderview.plus.plg b/folderview.plus.plg index afae2c98..1019ed99 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,20 +18,20 @@ -###2026.08.19.07 -- Fix: Eliminate the 5–7 second Docker-page refresh loop caused by API container-identity differences recursively scheduling native list reloads after FolderView renders. -- Fix: Prevent FolderView-generated images with blank or unreachable icon URLs from repeatedly requesting the failed source during subsequent FolderView renders, while immediately using the local fallback for blank values. -- Fix: Restore Docker folder boxes created through the Add Folder wizard when Unraid identifies container rows by short container IDs instead of container names. -- Fix: Keep populated folders visible when Hide empty folders removes earlier placeholders and later populated folders have high saved-order positions. -- Reliability: Keep Unraid's native Docker/PHP configuration revision authoritative for structural changes, use GraphQL only to enrich containers already present in the host runtime map, and preserve native rows during Docker busy-poll cycles. -- Reliability: Resolve Docker row identities through the normalized runtime model, retain deterministic first-folder ownership for overlapping rules, insert folder shells relative to connected host rows, and leave member rows under Unraid ownership if a shell cannot be attached safely. -- Performance: Replace broad action-followup redraws with incremental runtime reconciliation and remove duplicate deferred support-snapshot scheduling to avoid unnecessary request bundles, folder grouping, preview hydration, and DOM replacement. -- Diagnostics: Retain five bounded Docker-page session summaries with privacy-safe reload-source attribution, render and request counts, cadence, busy-cycle recovery, API-mismatch aggregates, and the `FVPLUS-DKR-REFRESH-001` healthy/suspected/confirmed verdict. -- Diagnostics: Count completed renders by monotonic generation, keep trace-health timestamps current, age browser-error evidence, and report aggregate host-row resolution, folder claims, missing members, hide-empty removals, and folder-shell insertion outcomes. -- Privacy: Keep runtime, refresh, API, icon, and folder-grouping diagnostics bounded and free of container identities, raw API payloads, GraphQL queries, URLs, tokens, cookies, call stacks, process lists, and syslogs. -- Docs: Expand troubleshooting and Docker runtime/API architecture guidance for native refresh ownership, busy polling, identity mismatches, folder grouping, support-bundle interpretation, and safe escalation. -- Test: Add coordinator, native busy-indicator, double-reload, image fallback, short-ID grouping, high-position hide-empty, repeated-render, browser-fixture, support-bundle, and large-runtime performance regressions. -- Quality: Validate the complete stable promotion through strict lint, static analysis, unit, workflow, release, install, deterministic-build, browser, theme, responsive, performance, dependency, Git-integrity, and GitHub security checks. +###2026.08.20.02 +- Fix: Allow protected FolderView Plus actions through a correctly configured TLS-terminating reverse proxy by validating the external host, protocol, and port as one coherent authority. +- Security: Keep strict POST, request-marker, install-token, one-time nonce, transaction, same-origin, and rate-limit controls while rejecting partial, repeated, malformed, spoofed-host, or conflicting forwarded headers. +- Diagnostics: Add privacy-safe request-security reason codes to diagnostics and support bundles without recording hostnames, addresses, ports, or raw forwarded-header values. +- Docs: Document supported SWAG forwarding, recovery from `Blocked by request guard`, and unsafe proxy workarounds to avoid. +- Test: Cover direct HTTP/HTTPS, standard and nonstandard proxy ports, IPv6, malformed or spoofed forwarded values, and Origin/Referer disagreements. + + +###2026.08.20.01 +- Fix: Allow protected FolderView Plus actions through a correctly configured TLS-terminating reverse proxy by validating the external `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` authority as one coherent tuple. +- Security: Keep strict POST, request-marker, install-token, one-time nonce, transaction, same-origin, and rate-limit controls unchanged; reject partial, repeated, malformed, spoofed-host, and conflicting-port forwarded headers. +- Diagnostics: Add privacy-safe request-security reason codes to diagnostics and support bundles without recording hostnames, addresses, ports, or raw forwarded-header values. +- Docs: Add SWAG reverse-proxy requirements, a troubleshooting flow for `Blocked by request guard`, and guidance not to inject `X-FV-Request` or remove browser origin headers at the proxy. +- Test: Cover direct HTTP/HTTPS, standard SWAG forwarding, nonstandard ports, IPv6, spoofed and malformed tuples, and independent Origin/Referer disagreement. ###2026.08.19.06 diff --git a/folderview.plus.xml b/folderview.plus.xml index 002d80e1..0b68f67e 100644 --- a/folderview.plus.xml +++ b/folderview.plus.xml @@ -10,7 +10,7 @@ FolderView Plus organizes Docker, VM, and Dashboard views into folders in Unraid, with starter setup tools, rules, bulk assignment, recovery, templates, and diagnostics. -2026-08-19 +2026-08-20 7.0.0 folder view docker vm dashboard organization groups sorting import export backup recovery rules templates diagnostics https://forums.unraid.net/topic/197631-plugin-folderview-plus/ diff --git a/scripts/build_release_notes.sh b/scripts/build_release_notes.sh index 3da72c04..592559b1 100644 --- a/scripts/build_release_notes.sh +++ b/scripts/build_release_notes.sh @@ -51,7 +51,7 @@ fi OVERRIDE_FILE="docs/releases/${VERSION}.md" append_provenance() { - local source_commit previous_ref compare_url archive_checksum total_commits + local source_commit previous_ref compare_url archive_checksum source_commit="$(git rev-parse HEAD)" previous_ref="${FVPLUS_RELEASE_PREVIOUS_REF:-$(git describe --tags --abbrev=0 --match 'v*' HEAD 2>/dev/null || true)}" archive_checksum="" @@ -69,13 +69,6 @@ append_provenance() { # shellcheck disable=SC2016 printf -- '- Previous stable reference: `%s`\n' "${previous_ref}" printf -- '- Full source comparison: %s\n' "${compare_url}" - printf '\n### Included commit history\n\n' - # shellcheck disable=SC2016 - git log --no-merges --format='- `%h` %s' "${previous_ref}..HEAD" --max-count=75 - total_commits="$(git rev-list --count --no-merges "${previous_ref}..HEAD")" - if (( total_commits > 75 )); then - printf -- '- …and %s earlier commit(s); use the full source comparison above.\n' "$((total_commits - 75))" - fi else printf -- '- Previous stable reference: unavailable\n' fi diff --git a/scripts/release_notes_consistency_guard.sh b/scripts/release_notes_consistency_guard.sh index fa65bf6d..4216e646 100644 --- a/scripts/release_notes_consistency_guard.sh +++ b/scripts/release_notes_consistency_guard.sh @@ -70,6 +70,9 @@ if (!rendered.includes(`## FolderView Plus ${version}`)) { if (!rendered.includes('### Changes')) { fail('Generated release notes are missing the changes heading.'); } +if (/^### Included commit history\s*$/m.test(rendered)) { + fail('Generated release notes must not include commit history.'); +} if (hasOverride) { if (!overrideBody) { fail(`Curated release override ${path.relative(root, overridePath)} is empty.`); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json index 48571192..617fef89 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/schemas/architecture-contracts.schema.json @@ -10,6 +10,7 @@ "scripts/folderviewplus.js": { "limit": 11793, "target": 1200, "history": [12092, 11793] }, "scripts/docker.js": { "limit": 7986, "target": 1000, "history": [8839, 8045, 8028, 7986] }, "server/lib.php": { "limit": 2649, "target": 400, "history": [7556, 2649] }, + "server/lib.request-authority.php": { "limit": 190, "target": 160, "history": [190] }, "scripts/folder.js": { "limit": 4773, "target": 700, "history": [5522, 4773] }, "scripts/folderviewplus.wizard.js": { "limit": 4165, "target": 500, "history": [4849, 4165] }, "scripts/vm.js": { "limit": 3945, "target": 750, "history": [4010, 3945] }, @@ -195,7 +196,7 @@ "kind": "server-facade", "stateModel": "request-scoped", "consumers": ["plugin-pages", "server-endpoints"], - "dependsOn": ["server/lib.remote.php", "server/lib.process.php", "server/lib.filesystem-security.php", "server/lib.security.php", "server/lib.api-contract.php", "server/lib.preflight.php", "server/lib.prefs.php", "server/lib.diagnostics.php", "server/lib.docker-runtime.php", "server/lib.input-security.php", "server/lib.release-notes.php", "server/lib.update-channel.php", "server/lib.storage.php", "server/lib.theme-profiles.php", "server/lib.theme-update-plan.php", "server/lib.theme-workspace.php", "server/lib.environment-snapshot.php", "server/lib.environment-transaction.php", "server/lib.folderview3-migration.php", "server/lib.folderview3-apply.php", "server/lib.theme-github.php", "server/lib.theme-profile-actions.php", "server/lib.theme-bulk-update.php", "server/lib.backup-schedule.php", "server/lib.backup-snapshots.php", "server/lib.folder-rules.php", "server/lib.docker-start-order-sequence.php", "server/lib.docker-order.php", "server/lib.folder-mutations.php", "server/lib.custom-icon-storage.php", "server/lib.runtime-info.php"], + "dependsOn": ["server/lib.remote.php", "server/lib.process.php", "server/lib.filesystem-security.php", "server/lib.security.php", "server/lib.request-authority.php", "server/lib.api-contract.php", "server/lib.preflight.php", "server/lib.prefs.php", "server/lib.diagnostics.php", "server/lib.docker-runtime.php", "server/lib.input-security.php", "server/lib.release-notes.php", "server/lib.update-channel.php", "server/lib.storage.php", "server/lib.theme-profiles.php", "server/lib.theme-update-plan.php", "server/lib.theme-workspace.php", "server/lib.environment-snapshot.php", "server/lib.environment-transaction.php", "server/lib.folderview3-migration.php", "server/lib.folderview3-apply.php", "server/lib.theme-github.php", "server/lib.theme-profile-actions.php", "server/lib.theme-bulk-update.php", "server/lib.backup-schedule.php", "server/lib.backup-snapshots.php", "server/lib.folder-rules.php", "server/lib.docker-start-order-sequence.php", "server/lib.docker-order.php", "server/lib.folder-mutations.php", "server/lib.custom-icon-storage.php", "server/lib.runtime-info.php"], "lifecycle": { "startup": "require-once", "teardown": "request-end" }, "compatibilityGlobals": [] }, @@ -221,6 +222,16 @@ } ], "serverModuleContracts": [ + { + "file": "server/lib.request-authority.php", + "owner": "server-request-authority", + "consumers": ["plugin-pages", "server-endpoints"], + "stateModel": "request-scoped", + "dependsOn": [], + "compatibilityGlobals": [], + "loadedBy": "server/lib.php", + "functionInventory": { "count": 11, "sha256": "4b55393420a54ede2692f9513c50d19aa31a49e4950bcbd155c3de720294b254" } + }, { "file": "server/lib.release-notes.php", "owner": "server-release-metadata", diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php index b953b3d0..53ecd59b 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php @@ -515,10 +515,10 @@ function diagnosticsBuildSupportBundleSystemSection(array $diagnostics, array $i 'durableStorage' => is_array($diagnostics['durableStorage'] ?? null) ? $diagnostics['durableStorage'] : [], 'runtimeIntegrity' => is_array($diagnostics['runtimeIntegrity'] ?? null) ? $diagnostics['runtimeIntegrity'] : [], 'securityAudit' => is_array($diagnostics['securityAudit'] ?? null) ? $diagnostics['securityAudit'] : [], + 'requestSecurity' => is_array($diagnostics['requestSecurity'] ?? null) ? $diagnostics['requestSecurity'] : [], 'phpExtensions' => array_values(get_loaded_extensions()) ]; } - function diagnosticsBuildSupportBundleHealthAndHistorySection(array $diagnostics, array $integrityFindings, array &$redactor): array { $summary = is_array($diagnostics['summary'] ?? null) ? $diagnostics['summary'] : []; $history = is_array($diagnostics['importExportHistory'] ?? null) ? $diagnostics['importExportHistory'] : []; 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 35d70914..ee46244b 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 @@ -130,7 +130,6 @@ function appendDiagnosticsHistoryEvent(string $action, ?string $type = null, arr } require_once(__DIR__ . '/lib.diagnostics-redaction.php'); - require_once(__DIR__ . '/lib.diagnostics-integrity.php'); require_once(__DIR__ . '/lib.diagnostics-summary.php'); @@ -239,6 +238,7 @@ function getDiagnosticsSnapshot(string $privacyMode = FVPLUS_DIAGNOSTICS_DEFAULT 'durableStorage' => getDurableStorageRuntimeSnapshot(), 'runtimeIntegrity' => $runtimeIntegrity, 'securityAudit' => $securityAudit, + 'requestSecurity' => getMutationRequestSecurityDiagnostics(), 'hashes' => getDiagnosticsKeyFileHashes($privacyMode), 'customIcons' => $customIcons, 'importExportHistory' => [ 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 cb3f9d15..d165678c 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 @@ -3,6 +3,7 @@ require_once __DIR__ . '/lib.process.php'; require_once __DIR__ . '/lib.filesystem-security.php'; require_once __DIR__ . '/lib.security.php'; + require_once __DIR__ . '/lib.request-authority.php'; define('FV3_DEBUG_MODE', false); // << SET TO true TO ENABLE LOGGING TO FILE >> $fv3_debug_log_file = "/tmp/folder_view3_php_debug.log"; @@ -753,90 +754,6 @@ function emitRequestTraceHeader(): void { } } - function normalizeHostForCompare(string $host): string { - $host = strtolower(trim($host)); - if ($host === '') { - return ''; - } - if ($host[0] === '[' && substr($host, -1) === ']') { - return substr($host, 1, -1); - } - return $host; - } - - function parseHostPortFromUrl(string $url): array { - $parts = @parse_url($url); - if (!is_array($parts)) { - return ['', null]; - } - $host = normalizeHostForCompare((string)($parts['host'] ?? '')); - if ($host === '') { - return ['', null]; - } - $port = isset($parts['port']) ? (int)$parts['port'] : null; - if ($port === null) { - $scheme = strtolower((string)($parts['scheme'] ?? '')); - if ($scheme === 'http') { - $port = 80; - } elseif ($scheme === 'https') { - $port = 443; - } - } - return [$host, $port]; - } - - function parseCurrentRequestHostPort(): array { - $hostHeader = trim((string)($_SERVER['HTTP_HOST'] ?? '')); - if ($hostHeader === '') { - return ['', null]; - } - $isHttps = !empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off'; - $probeUrl = ($isHttps ? 'https://' : 'http://') . $hostHeader; - [$host, $port] = parseHostPortFromUrl($probeUrl); - if ($host === '') { - return ['', null]; - } - if ($port === null && isset($_SERVER['SERVER_PORT'])) { - $serverPort = (int)$_SERVER['SERVER_PORT']; - if ($serverPort > 0) { - $port = $serverPort; - } - } - return [$host, $port]; - } - - function isSameOriginHeaderValue(string $urlValue): bool { - if ($urlValue === '' || strtolower($urlValue) === 'null') { - return false; - } - [$requestHost, $requestPort] = parseCurrentRequestHostPort(); - if ($requestHost === '') { - return false; - } - [$headerHost, $headerPort] = parseHostPortFromUrl($urlValue); - if ($headerHost === '' || $headerHost !== $requestHost) { - return false; - } - if ($headerPort !== null && $requestPort !== null && $headerPort !== $requestPort) { - return false; - } - return true; - } - - function isTrustedMutationContext(): bool { - // Do not require client headers to exist; only block when a provided - // Origin/Referer explicitly points to a different host/port. - $origin = getRequestHeaderValue('Origin'); - if ($origin !== '' && !isSameOriginHeaderValue($origin)) { - return false; - } - $referer = getRequestHeaderValue('Referer'); - if ($referer !== '' && !isSameOriginHeaderValue($referer)) { - return false; - } - return true; - } - function getOptionalRequestTokenPath(): string { global $configDir; return "$configDir/request.token"; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.request-authority.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.request-authority.php new file mode 100644 index 00000000..a3d793b9 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.request-authority.php @@ -0,0 +1,189 @@ + 2048 || preg_match('/[\x00-\x1F\x7F]/', $url)) { + return []; + } + $parts = @parse_url($url); + if (!is_array($parts)) { + return []; + } + $scheme = strtolower(trim((string)($parts['scheme'] ?? ''))); + if (!in_array($scheme, ['http', 'https'], true) || isset($parts['user']) || isset($parts['pass'])) { + return []; + } + $host = normalizeHostForCompare((string)($parts['host'] ?? '')); + if ($host === '') { + return []; + } + $explicitPort = isset($parts['port']); + $port = $explicitPort ? (int)$parts['port'] : ($scheme === 'https' ? 443 : 80); + if ($port < 1 || $port > 65535) { + return []; + } + return [ + 'scheme' => $scheme, + 'host' => $host, + 'port' => $port, + 'explicitPort' => $explicitPort + ]; +} + +function parseHostPortFromUrl(string $url): array { + $authority = parseRequestUrlAuthority($url); + return empty($authority) ? ['', null] : [(string)$authority['host'], (int)$authority['port']]; +} + +function parseRequestHostAuthority(string $hostHeader, string $scheme): array { + $hostHeader = trim($hostHeader); + $scheme = strtolower(trim($scheme)); + if ( + $hostHeader === '' + || strlen($hostHeader) > 512 + || !in_array($scheme, ['http', 'https'], true) + || preg_match('~[\x00-\x20\x7F,@/\\\\?#]~', $hostHeader) + ) { + return []; + } + return parseRequestUrlAuthority($scheme . '://' . $hostHeader); +} + +function parseCurrentRequestAuthority(): array { + $hostHeader = trim((string)($_SERVER['HTTP_HOST'] ?? '')); + $isHttps = !empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off'; + return parseRequestHostAuthority($hostHeader, $isHttps ? 'https' : 'http'); +} + +function parseCurrentRequestHostPort(): array { + $authority = parseCurrentRequestAuthority(); + return empty($authority) ? ['', null] : [(string)$authority['host'], (int)$authority['port']]; +} + +function resolveForwardedRequestAuthority(): array { + $forwardedHost = getRequestHeaderValue('X-Forwarded-Host'); + $forwardedProto = getRequestHeaderValue('X-Forwarded-Proto'); + $forwardedPort = getRequestHeaderValue('X-Forwarded-Port'); + $providedCount = ($forwardedHost !== '' ? 1 : 0) + + ($forwardedProto !== '' ? 1 : 0) + + ($forwardedPort !== '' ? 1 : 0); + if ($providedCount === 0) { + return ['status' => 'absent']; + } + if ($providedCount !== 3) { + return ['status' => 'partial']; + } + foreach ([$forwardedHost, $forwardedProto, $forwardedPort] as $value) { + if (strlen($value) > 512 || strpos($value, ',') !== false || preg_match('/[\x00-\x1F\x7F]/', $value)) { + return ['status' => 'invalid']; + } + } + $forwardedProto = strtolower($forwardedProto); + if (!in_array($forwardedProto, ['http', 'https'], true)) { + return ['status' => 'invalid-proto']; + } + if (!preg_match('/^[0-9]{1,5}$/', $forwardedPort)) { + return ['status' => 'invalid-port']; + } + $port = (int)$forwardedPort; + if ($port < 1 || $port > 65535) { + return ['status' => 'invalid-port']; + } + [$directHost] = parseCurrentRequestHostPort(); + $forwardedAuthority = parseRequestHostAuthority($forwardedHost, $forwardedProto); + if ($directHost === '' || empty($forwardedAuthority)) { + return ['status' => 'invalid-host']; + } + if ((string)$forwardedAuthority['host'] !== $directHost) { + return ['status' => 'host-mismatch']; + } + if (!empty($forwardedAuthority['explicitPort']) && (int)$forwardedAuthority['port'] !== $port) { + return ['status' => 'port-mismatch']; + } + $forwardedAuthority['port'] = $port; + $forwardedAuthority['status'] = 'valid'; + return $forwardedAuthority; +} + +function evaluateSameOriginHeaderValue(string $urlValue): array { + if ($urlValue === '' || strtolower($urlValue) === 'null') { + return ['trusted' => false, 'status' => 'invalid']; + } + [$headerHost, $headerPort] = parseHostPortFromUrl($urlValue); + $headerAuthority = parseRequestUrlAuthority($urlValue); + if ($headerHost === '' || $headerPort === null || empty($headerAuthority)) { + return ['trusted' => false, 'status' => 'invalid']; + } + $directAuthority = parseCurrentRequestAuthority(); + if ( + !empty($directAuthority) + && (string)$headerAuthority['scheme'] === (string)$directAuthority['scheme'] + && $headerHost === (string)$directAuthority['host'] + && $headerPort === (int)$directAuthority['port'] + ) { + return ['trusted' => true, 'status' => 'direct']; + } + $forwardedAuthority = resolveForwardedRequestAuthority(); + if ( + ($forwardedAuthority['status'] ?? '') === 'valid' + && (string)$headerAuthority['scheme'] === (string)$forwardedAuthority['scheme'] + && $headerHost === (string)$forwardedAuthority['host'] + && $headerPort === (int)$forwardedAuthority['port'] + ) { + return ['trusted' => true, 'status' => 'forwarded']; + } + return ['trusted' => false, 'status' => 'mismatch']; +} + +function isSameOriginHeaderValue(string $urlValue): bool { + return !empty(evaluateSameOriginHeaderValue($urlValue)['trusted']); +} + +function getMutationRequestSecurityDiagnostics(): array { + $origin = getRequestHeaderValue('Origin'); + $referer = getRequestHeaderValue('Referer'); + $originResult = $origin === '' + ? ['trusted' => true, 'status' => 'not-provided'] + : evaluateSameOriginHeaderValue($origin); + $refererResult = $referer === '' + ? ['trusted' => true, 'status' => 'not-provided'] + : evaluateSameOriginHeaderValue($referer); + $sources = []; + foreach ([$originResult, $refererResult] as $result) { + if (in_array(($result['status'] ?? ''), ['direct', 'forwarded'], true)) { + $sources[] = (string)$result['status']; + } + } + $sources = array_values(array_unique($sources)); + $authoritySource = empty($sources) ? 'not-required' : (count($sources) === 1 ? $sources[0] : 'mixed'); + return [ + 'schemaVersion' => 1, + 'enforcementMode' => getRequestTokenEnforcementMode(), + 'trustedContext' => !empty($originResult['trusted']) && !empty($refererResult['trusted']), + 'authoritySource' => $authoritySource, + 'forwardedAuthorityStatus' => (string)(resolveForwardedRequestAuthority()['status'] ?? 'invalid'), + 'originStatus' => (string)($originResult['status'] ?? 'invalid'), + 'refererStatus' => (string)($refererResult['status'] ?? 'invalid') + ]; +} + +function isTrustedMutationContext(): bool { + // Missing browser origin headers remain allowed for compatibility; supplied + // values must match either the direct or one coherent forwarded authority. + $origin = getRequestHeaderValue('Origin'); + if ($origin !== '' && !isSameOriginHeaderValue($origin)) { + return false; + } + $referer = getRequestHeaderValue('Referer'); + return $referer === '' || isSameOriginHeaderValue($referer); +} diff --git a/tests/architecture-boundaries.test.mjs b/tests/architecture-boundaries.test.mjs index 3205283e..aeff5787 100644 --- a/tests/architecture-boundaries.test.mjs +++ b/tests/architecture-boundaries.test.mjs @@ -22,7 +22,7 @@ test('entrypoints and contracted modules declare ownership boundaries', () => { assert.equal(architecture.schemaVersion, 2); assert.equal(architecture.entrypointContracts.length, 9); assert.equal(architecture.moduleContracts.length, 50); - assert.equal(architecture.serverModuleContracts.length, 28); + assert.equal(architecture.serverModuleContracts.length, 29); const allowedConsumers = new Set(architecture.consumerScopes); for (const contract of [...architecture.entrypointContracts, ...architecture.moduleContracts, ...architecture.serverModuleContracts]) { assert.ok(contract.file, 'boundary contract must name its file'); diff --git a/tests/icon-asset-pack.test.mjs b/tests/icon-asset-pack.test.mjs index d6af05fb..410d45a7 100644 --- a/tests/icon-asset-pack.test.mjs +++ b/tests/icon-asset-pack.test.mjs @@ -50,6 +50,9 @@ const toBashPath = (value) => { const match = normalized.match(/^([A-Za-z]):\/(.*)$/); return match ? `/mnt/${match[1].toLowerCase()}/${match[2]}` : normalized; }; +const readArchiveWithTar = (args) => process.platform === 'win32' + ? execFileSync('wsl.exe', ['--exec', 'tar', ...args], { cwd: repoRoot, encoding: 'utf8' }) + : execFileSync('tar', args, { cwd: repoRoot, encoding: 'utf8' }); const installerInvocation = (pluginDir, configDir, cacheDir, checksum) => { const environment = { FVPLUS_PLUGIN_DIR: toBashPath(pluginDir), @@ -166,7 +169,8 @@ test('manifest resolves and requires the installed asset-pack activation helper' }); test('asset pack contains only its manifest and supported runtime icons', () => { - const entries = execFileSync('tar', ['-tf', archivePath], { encoding: 'utf8' }) + const readableArchivePath = process.platform === 'win32' ? toBashPath(archivePath) : archivePath; + const entries = readArchiveWithTar(['-tf', readableArchivePath]) .split(/\r?\n/) .filter(Boolean); assert.ok(entries.includes('asset-pack.json')); @@ -176,7 +180,7 @@ test('asset pack contains only its manifest and supported runtime icons', () => return !/^third-party-icons\/.*\.(?:png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(entry); }); assert.deepEqual(invalid, []); - const embedded = JSON.parse(execFileSync('tar', ['-xOf', archivePath, 'asset-pack.json'], { encoding: 'utf8' })); + const embedded = JSON.parse(readArchiveWithTar(['-xOf', readableArchivePath, 'asset-pack.json'])); assert.equal(embedded.id, 'folderview.plus-icons'); assert.equal(embedded.version, version); assert.equal(embedded.fileCount, countSourceIcons(iconSourceRoot)); diff --git a/tests/phase6-php-decomposition.test.mjs b/tests/phase6-php-decomposition.test.mjs index c81fd688..dea83e82 100644 --- a/tests/phase6-php-decomposition.test.mjs +++ b/tests/phase6-php-decomposition.test.mjs @@ -40,7 +40,7 @@ test('Phase 6 preserves the complete public endpoint manifest', () => { }); test('facades load every contracted PHP module without retaining extracted implementations', () => { - assert.equal(architecture.serverModuleContracts.length, 28); + assert.equal(architecture.serverModuleContracts.length, 29); for (const contract of architecture.serverModuleContracts) { const loader = contract.loadedBy === 'server/lib.php' ? coreFacade @@ -56,7 +56,7 @@ test('facades load every contracted PHP module without retaining extracted imple test('decomposition preserves every historical public PHP function name', () => { const expected = { - 'server/lib.php': { count: 326, sha256: '5f3196b8d4e331a117b60dbb3da5146a4e5d87da5c38a47061bd81da61116c97' }, + 'server/lib.php': { count: 332, sha256: '3402a8afdb4a90878b5a5cc26284075606051f35819e2859df9bc23a2b8612c3' }, 'server/lib.diagnostics.php': { count: 74, sha256: 'a1d008eb74affc973be697a63837347f992ff932591d6aa630b2a4d022a24b1a' }, 'server/upload_custom_icon.php': { count: 56, sha256: '825b3fbb5288c76f83a4976a55967e8a9a205e5496581f3a0c842789083f530e' } }; diff --git a/tests/request-guard-proxy.test.mjs b/tests/request-guard-proxy.test.mjs new file mode 100644 index 00000000..daa152f7 --- /dev/null +++ b/tests/request-guard-proxy.test.mjs @@ -0,0 +1,185 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const repoRoot = path.resolve(process.cwd()); +const libPath = path.join( + repoRoot, + 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php' +); + +const phpSingleQuote = (value) => `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + +const evaluateRequest = (server) => { + const sandboxRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fvplus-request-guard-')); + const configDir = path.join(sandboxRoot, 'config'); + const sourceDir = path.join(sandboxRoot, 'source'); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(sourceDir, { recursive: true }); + const encodedServer = Buffer.from(JSON.stringify(server), 'utf8').toString('base64'); + const php = ` +$_SERVER = json_decode(base64_decode(${phpSingleQuote(encodedServer)}), true); +require_once ${phpSingleQuote(libPath)}; +echo json_encode([ + 'trusted' => isTrustedMutationContext(), + 'diagnostics' => getMutationRequestSecurityDiagnostics() +], JSON_UNESCAPED_SLASHES); +`; + try { + const output = execFileSync('php', ['-r', php], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + FVPLUS_TEST_CONFIG_DIR: configDir, + FVPLUS_TEST_SOURCE_DIR: sourceDir + } + }); + return JSON.parse(output); + } finally { + fs.rmSync(sandboxRoot, { recursive: true, force: true }); + } +}; + +const standardProxyHeaders = { + HTTP_HOST: 'unraid.example.test', + HTTPS: 'off', + SERVER_PORT: '80', + HTTP_X_FORWARDED_HOST: 'unraid.example.test', + HTTP_X_FORWARDED_PROTO: 'https', + HTTP_X_FORWARDED_PORT: '443', + HTTP_ORIGIN: 'https://unraid.example.test', + HTTP_REFERER: 'https://unraid.example.test/Settings/FolderViewPlus' +}; + +test('direct HTTP and HTTPS request authorities remain trusted', () => { + const httpResult = evaluateRequest({ + HTTP_HOST: 'tower.local', + HTTPS: 'off', + SERVER_PORT: '80', + HTTP_ORIGIN: 'http://tower.local', + HTTP_REFERER: 'http://tower.local/Docker' + }); + assert.equal(httpResult.trusted, true); + assert.equal(httpResult.diagnostics.authoritySource, 'direct'); + assert.equal(httpResult.diagnostics.forwardedAuthorityStatus, 'absent'); + + const httpsResult = evaluateRequest({ + HTTP_HOST: 'tower.local:8443', + HTTPS: 'on', + SERVER_PORT: '8443', + HTTP_ORIGIN: 'https://tower.local:8443' + }); + assert.equal(httpsResult.trusted, true); + assert.equal(httpsResult.diagnostics.originStatus, 'direct'); +}); + +test('a coherent standard reverse-proxy authority is trusted', () => { + const result = evaluateRequest(standardProxyHeaders); + assert.equal(result.trusted, true); + assert.deepEqual(result.diagnostics, { + schemaVersion: 1, + enforcementMode: 'strict', + trustedContext: true, + authoritySource: 'forwarded', + forwardedAuthorityStatus: 'valid', + originStatus: 'forwarded', + refererStatus: 'forwarded' + }); +}); + +test('a coherent forwarded authority supports nonstandard ports and IPv6', () => { + const nonstandardPort = evaluateRequest({ + ...standardProxyHeaders, + HTTP_X_FORWARDED_PORT: '4443', + HTTP_ORIGIN: 'https://unraid.example.test:4443', + HTTP_REFERER: 'https://unraid.example.test:4443/Docker' + }); + assert.equal(nonstandardPort.trusted, true); + assert.equal(nonstandardPort.diagnostics.forwardedAuthorityStatus, 'valid'); + + const ipv6 = evaluateRequest({ + HTTP_HOST: '[fd00::10]', + HTTPS: 'off', + SERVER_PORT: '80', + HTTP_X_FORWARDED_HOST: '[fd00::10]', + HTTP_X_FORWARDED_PROTO: 'https', + HTTP_X_FORWARDED_PORT: '4443', + HTTP_ORIGIN: 'https://[fd00::10]:4443', + HTTP_REFERER: 'https://[fd00::10]:4443/Docker' + }); + assert.equal(ipv6.trusted, true); + assert.equal(ipv6.diagnostics.authoritySource, 'forwarded'); +}); + +test('forwarded authority fails closed for spoofed, partial, repeated, and conflicting headers', () => { + const cases = [ + { + expectedStatus: 'host-mismatch', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_HOST: 'attacker.example.test' } + }, + { + expectedStatus: 'partial', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_PORT: '' } + }, + { + expectedStatus: 'invalid', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_HOST: 'unraid.example.test, attacker.example.test' } + }, + { + expectedStatus: 'port-mismatch', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_HOST: 'unraid.example.test:4443' } + }, + { + expectedStatus: 'invalid-proto', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_PROTO: 'javascript' } + }, + { + expectedStatus: 'invalid-port', + server: { ...standardProxyHeaders, HTTP_X_FORWARDED_PORT: '70000' } + } + ]; + + for (const fixture of cases) { + const result = evaluateRequest(fixture.server); + assert.equal(result.trusted, false, fixture.expectedStatus); + assert.equal(result.diagnostics.forwardedAuthorityStatus, fixture.expectedStatus); + assert.equal(result.diagnostics.trustedContext, false); + } +}); + +test('Origin and Referer must each agree with a trusted authority', () => { + const result = evaluateRequest({ + ...standardProxyHeaders, + HTTP_REFERER: 'https://attacker.example.test/Docker' + }); + assert.equal(result.trusted, false); + assert.equal(result.diagnostics.originStatus, 'forwarded'); + assert.equal(result.diagnostics.refererStatus, 'mismatch'); + + const nullOrigin = evaluateRequest({ + ...standardProxyHeaders, + HTTP_ORIGIN: 'null', + HTTP_REFERER: '' + }); + assert.equal(nullOrigin.trusted, false); + assert.equal(nullOrigin.diagnostics.originStatus, 'invalid'); +}); + +test('request security diagnostics expose reason codes without authority values', () => { + const result = evaluateRequest(standardProxyHeaders); + const serialized = JSON.stringify(result.diagnostics); + assert.doesNotMatch(serialized, /unraid\.example\.test|tower\.local|fd00/i); + assert.deepEqual(Object.keys(result.diagnostics).sort(), [ + 'authoritySource', + 'enforcementMode', + 'forwardedAuthorityStatus', + 'originStatus', + 'refererStatus', + 'schemaVersion', + 'trustedContext' + ]); +}); diff --git a/tests/support-bundle-v2-contract.test.mjs b/tests/support-bundle-v2-contract.test.mjs index 2a1f83d5..3efe2bbc 100644 --- a/tests/support-bundle-v2-contract.test.mjs +++ b/tests/support-bundle-v2-contract.test.mjs @@ -83,6 +83,15 @@ $diagnostics = [ 'clientIpHash' => 'ip-static' ] ], + 'requestSecurity' => [ + 'schemaVersion' => 1, + 'enforcementMode' => 'strict', + 'trustedContext' => true, + 'authoritySource' => 'forwarded', + 'forwardedAuthorityStatus' => 'valid', + 'originStatus' => 'forwarded', + 'refererStatus' => 'forwarded' + ], 'hashes' => [ 'dockerFolders' => [ 'file' => 'docker.folder.json', @@ -723,6 +732,15 @@ test('sanitized support bundle fixture redacts paths, names, URLs, IPs, and user assert.match(bundle.system.request.userAgentHash, /^[0-9a-f]{16}$/); assert.equal(bundle.system.request.clientIp, '192.168.x.x'); assert.match(bundle.system.request.clientIpHash, /^[0-9a-f]{16}$/); + assert.deepEqual(bundle.system.requestSecurity, { + schemaVersion: 1, + enforcementMode: 'strict', + trustedContext: true, + authoritySource: 'forwarded', + forwardedAuthorityStatus: 'valid', + originStatus: 'forwarded', + refererStatus: 'forwarded' + }); assert.equal(bundle.system.pathHealth.customIcons.path.path, 'PlexSecretIcon.png'); assert.match(bundle.system.pathHealth.customIcons.path.pathHash, /^[0-9a-f]{16}$/); assert.equal(bundle.system.pathHealth.customIcons.topReferences[0].name, null); @@ -816,6 +834,8 @@ test('full support bundle fixture keeps raw troubleshooting fields and disables assert.match(bundle.system.request.userAgentHash, /^[0-9a-f]{12}$/); assert.equal(bundle.system.request.clientIp, '192.168.6.25'); assert.match(bundle.system.request.clientIpHash, /^[0-9a-f]{12}$/); + assert.equal(bundle.system.requestSecurity.forwardedAuthorityStatus, 'valid'); + assert.equal(bundle.system.requestSecurity.authoritySource, 'forwarded'); assert.equal(bundle.system.pathHealth.customIcons.path.path, '/boot/config/plugins/folderview.plus/images/custom/PlexSecretIcon.png'); assert.equal(bundle.system.pathHealth.customIcons.topReferences[0].name, 'PlexMediaServer'); assert.equal(bundle.system.pathHealth.customIcons.repairHint, 'Delete /boot/config/plugins/folderview.plus/images/custom/PlexSecretIcon.png if unused.'); diff --git a/tests/versioning-guard.test.mjs b/tests/versioning-guard.test.mjs index 8da2f06a..b1e4e8ee 100644 --- a/tests/versioning-guard.test.mjs +++ b/tests/versioning-guard.test.mjs @@ -569,6 +569,14 @@ test('release notes builder supports curated per-version override files', () => assert.match(buildReleaseNotes, /cat "\$\{OVERRIDE_FILE\}"/); }); +test('release notes keep concise provenance without embedded commit history', () => { + assert.match(buildReleaseNotes, /### Release provenance/); + assert.match(buildReleaseNotes, /Full source comparison/); + assert.doesNotMatch(buildReleaseNotes, /git log --no-merges/); + assert.doesNotMatch(buildReleaseNotes, /### Included commit history/); + assert.match(releaseNotesConsistencyGuard, /Generated release notes must not include commit history/); +}); + test('release note parsers distinguish category headings from version headings', () => { const versionHeadingPattern = '/^###[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}[[:space:]]*$/'; for (const script of [buildReleaseNotes, ensureChanges, releaseGuard]) {