diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d0d24..df3510b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - Added narrowly scoped catalog request attempts for 82,865 ambiguity-safe records—70,841 movies and 12,024 TV series—while keeping their availability `unavailable` and visibly **not checked** in Seerr. Thirty-six groups share a strong importer identifier across 72 importable source records, including 59 otherwise eligible records—10 movies and 49 TV series; their ambiguous catalog materializations remain indexed for provenance and diagnostics but cannot independently surface in Finder or authorize request actions. Independently identified available Plex items remain discoverable, while catalog ambiguity still blocks preview and creation. Generic and verified-requestable-only searches exclude attempt rows, explicit request intent labels them separately, and preview remains non-writing until confirmation. - Bound request creation to the previewed media type, TMDB interoperability ID, seasons, phrase, and confirmation token so stale identity changes fail closed; unresolved operations now serialize the shared Seerr write globally per item across users. A fresh preview after a prior successful request is declined advances a durable confirmation generation instead of replaying the cached success, and successful generation markers are not removed by failed-operation cleanup. Request previews also gain an explicit Cancel action without overlapping card controls, and full catalog snapshots no longer perform startup-repair writes outside their atomic transaction. - Made the Unraid first-install ownership boundary explicit and fail-closed: the template and guide now require a fresh Appdata bind path to be pre-created as private UID/GID `999:999` storage before Apply, because Docker Manager otherwise creates missing paths as `99:100`; candidate evidence forbids hiding that mismatch with a post-Apply repair or world-writable permissions. Hosts whose Docker cannot enforce a swap ceiling must also prove zero usable host swap rather than claiming the configured no-extra-swap limit was applied. +- Made direct Unraid installs explicitly IP-canonical when using Docker Manager's WebUI shortcut, documented the separate hostname/HTTPS path, and replaced opaque origin-mismatch failures with actionable recovery guidance while preserving exact-origin CSRF enforcement. - Recorded version-pinned npm install-script approvals for the required `esbuild` binary setup and optional macOS `fsevents` binding, keeping clean installs explicit and warning-free under npm's install-script review policy. - Revoked the abandoned restrictive-umask candidate after testing exposed bind-mounted fixtures that were unreadable to unprivileged validation containers; the replacement harness explicitly normalizes their completed modes before read-only container use. diff --git a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md index 219b42b..31e8491 100644 --- a/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md +++ b/docs/BETA_CANDIDATE_MANUAL_VALIDATION.md @@ -186,7 +186,7 @@ Record `unraid.version`, `unraid.dockerVersion`, and `unraid.architecture`. Beta | `capabilitiesDropped` | All Linux capabilities are dropped and none are added. | | `resourceLimits` | Runtime limits are exactly 2 CPUs, 2 GiB memory, 128 PIDs, and a 512 MiB `/tmp` tmpfs using `rw,nosuid,nodev,noexec,mode=1777`. No extra swap is possible: either the container's swap limit is enforced at zero beyond its memory limit, or Docker reports no swap-limit support and the host has zero usable swap throughout the run. Available host swap without an enforced container limit leaves this field `false`. | | `healthy` | Docker health and `GET /api/health` remain healthy through startup, configuration, sync, and restart, with no restart, OOM, or fatal marker. | -| `exactOriginSession` | `MOODARR_WEB_ORIGIN` exactly matches the browser-visible origin, Admin token exchange creates the expected session there, a cookie-authenticated write succeeds from that origin, and a mismatched-origin write is rejected. | +| `exactOriginSession` | Before unlock, privately compare `MOODARR_WEB_ORIGIN`, the browser address-bar origin, and Docker Manager's WebUI shortcut target. Direct Unraid mode makes all three the same IP origin; custom hostname or HTTPS mode opens that configured origin directly and does not use the IP shortcut. Admin token exchange creates the expected host-scoped session there, a cookie-authenticated non-mutating POST succeeds from that origin, and the alternate IP or hostname origin is rejected. | | `restartPersistence` | A stop/start and container recreation preserve the private `/data` configuration, synced state, and expected user-facing behavior without relaxing permissions. | | `priorVersionUpdate` | The documented predecessor is installed on separate test data, backed up, and updated through Docker Manager to the exact candidate digest; expected state survives and the candidate is healthy. | | `cleanupComplete` | The dedicated test container, template changes, port, appdata, temporary backup, and operator-only files are removed or returned to their documented retained location without touching shared resources. | diff --git a/docs/UNRAID.md b/docs/UNRAID.md index f41aa9a..7af6f35 100644 --- a/docs/UNRAID.md +++ b/docs/UNRAID.md @@ -111,6 +111,12 @@ The example Compose file deliberately supplies operational defaults such as sync ## Unraid Template +### Choose one browser origin before Apply + +Unraid's **WebUI** action expands `[IP]` to the raw host IP and mapped Web UI port. For direct Unraid access, enter that exact IP origin in **Web Origin** and always use the same URL. If you configure a DNS hostname or an HTTPS reverse proxy instead, enter that canonical origin and open it directly rather than using the IP shortcut. Hostname and IP aliases are different origins even when they reach the same server. + +Mixing those addresses can let safe pages load and the Admin token exchange appear to succeed, but later cookie-authenticated `POST`, `PUT`, `PATCH`, and `DELETE` requests—including Finder search—are rejected. Reopen Moodarr at the configured origin and unlock Admin again because browser sessions are host-scoped. Do not enable automatic admin sessions or weaken origin protection to work around a mismatch. + ### Prepare appdata before first Apply Unraid Docker Manager creates a missing bind-mount source as host UID/GID `99:100`. Moodarr intentionally runs as non-root UID/GID `999:999`, with all capabilities dropped, so it cannot and should not repair host ownership during startup. Before selecting **Apply** for a fresh install, open the Unraid Terminal and create the exact path you will enter in the template's **Appdata** field: diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index bb81f9e..e93a5df 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -24,10 +24,10 @@ Before the first beta.1 start: 1. Before stopping alpha.21, record one deterministic AI-off requestable search query and the expected catalog-backed item it returns. This is the post-refresh discovery baseline; do not record private titles in public evidence. 2. Take a cold backup of the alpha.21 data mount and record the exact alpha.21 image digest. The alpha.21 Compose example used the `./data` bind mount beside the Compose file, while its Docker quick start used the `moodarr-data` named volume. 3. Keep that same bind path or named volume mounted at `/data`. If adopting the beta Compose file after using the alpha Compose example, replace its `moodarr-data:/data` mapping with the existing `./data:/data` bind mount for this upgrade. Switching mounts does not copy the alpha data. -4. Set `MOODARR_WEB_ORIGIN` to the one exact origin browsers use, including scheme and port, such as `http://192.0.2.10:4401`. This is required before production startup when Plex sign-in is enabled and is also the origin used for cookie-authenticated write protection. +4. Set `MOODARR_WEB_ORIGIN` to the one exact origin browsers use, including scheme and port, such as `http://192.0.2.10:4401`. Hostname and IP aliases are different origins. This is required before production startup when Plex sign-in is enabled and is also the origin used for cookie-authenticated write protection. 5. Keep a long random `MOODARR_ADMIN_TOKEN` and set `MOODARR_ADMIN_AUTO_SESSION=false`. Retain `true` only as an explicit trusted-LAN exception where every visitor is an administrator; it is incompatible with meaningful Plex-user/admin separation. 6. Apply the current container controls: `init: true`, a read-only root filesystem, a 512 MiB `/tmp` tmpfs, all capabilities dropped, `no-new-privileges`, PID/CPU/memory limits, and a stop grace period. The beta image runs as UID/GID `999:999`, so confirm the existing `/data` path remains writable by that identity before starting it. -7. For Unraid, use the current template fields and Extra Parameters while preserving the existing Appdata path. Add the exact Web Origin value, change Admin Container Session to `false` unless accepting the trusted-LAN exception above, and retain the beta template's resource and security options. +7. For Unraid, use the current template fields and Extra Parameters while preserving the existing Appdata path. For direct access, make Web Origin the exact IP origin opened by Docker Manager's WebUI shortcut. If using a hostname or HTTPS reverse proxy, enter and open that origin directly instead of the IP shortcut. Change Admin Container Session to `false` unless accepting the trusted-LAN exception above, and retain the beta template's resource and security options. 8. If alpha.21 imported catalog data, retain the original file or obtain an operator-approved authoritative snapshot from the same catalog pipeline. Record its source, version, provenance, and applicable license. The schema-29 boundary step within beta.1's final schema 31 never reconstructs trusted descriptions from Seerr/TMDB responses. Use the candidate's recorded immutable digest as the beta image reference during validation. After the migration passes, keep the alpha.21 backup and digest until beta.1 has completed normal sync and search activity. @@ -204,6 +204,8 @@ Preserve the existing `/data` mount and environment values. Do not use `down -v` 4. Apply the update and inspect the first-start logs. 5. Record the image digest shown by Docker after the pull. +After Apply, open the same canonical origin preserved in `MOODARR_WEB_ORIGIN`. If that value is a hostname or HTTPS reverse proxy, do not use Docker Manager's raw-IP WebUI shortcut. Unlock Admin again after an intentional origin change because browser sessions are host-scoped. + Do not replace the appdata mapping with an empty directory unless performing a deliberate restore or clean-install test. ## Post-Upgrade Validation diff --git a/scripts/test-packaging.ts b/scripts/test-packaging.ts index a2d9342..057783b 100644 --- a/scripts/test-packaging.ts +++ b/scripts/test-packaging.ts @@ -2169,9 +2169,12 @@ includes("docker-compose.example.yml", "cap_drop:"); includes("docker-compose.example.yml", "pids_limit: 128"); includes("docker-compose.example.yml", "size=512m"); includes("unraid/moodarr.xml", "Moodarr"); +includes("unraid/moodarr.xml", "http://[IP]:[PORT:4401]/"); if (read("unraid/moodarr.xml").includes("")) failures.push("unraid/moodarr.xml must not advertise an interactive shell in the distroless runtime"); includes("unraid/moodarr.xml", 'Target="MOODARR_ADMIN_AUTO_SESSION" Default="false"'); includes("unraid/moodarr.xml", 'Target="MOODARR_WEB_ORIGIN" Default=""'); +includes("unraid/moodarr.xml", "For direct Unraid access, enter the IP origin opened by the Docker WebUI shortcut"); +includes("unraid/moodarr.xml", "If using a hostname or HTTPS reverse proxy, enter that origin and do not use the IP shortcut"); includes("unraid/moodarr.xml", "Before first Apply, pre-create the selected Appdata host path as UID/GID 999:999 with mode 0700"); includes("unraid/moodarr.xml", "Before first Apply, pre-create this exact host path as UID/GID 999:999 with mode 0700 per docs/UNRAID.md"); includes(".github/workflows/release-verify.yml", "npm run verify:release"); diff --git a/scripts/verify-doc-contracts.ts b/scripts/verify-doc-contracts.ts index fa2e9a6..31b6f5a 100644 --- a/scripts/verify-doc-contracts.ts +++ b/scripts/verify-doc-contracts.ts @@ -95,6 +95,8 @@ for (const phrase of [ "install -d -m 0700 -o 999 -g 999", "printf 'Prepared %s as %s\\n'", "do not replace it with `chmod 777`", + "Unraid's **WebUI** action expands `[IP]` to the raw host IP and mapped Web UI port", + "browser sessions are host-scoped", "Such a host meets the beta resource envelope only while it has zero usable host swap" ]) { if (!unraidGuide.includes(phrase)) failures.push(`docs/UNRAID.md does not preserve the Apps template field guidance: ${phrase}`); @@ -103,6 +105,9 @@ for (const phrase of [ "Before selecting **Apply**, prove the exact appdata path is absent", "Do not let Docker Manager auto-create the path as `99:100`", "without any post-Apply ownership repair or permission relaxation", + "privately compare `MOODARR_WEB_ORIGIN`, the browser address-bar origin, and Docker Manager's WebUI shortcut target", + "Direct Unraid mode makes all three the same IP origin", + "a cookie-authenticated non-mutating POST succeeds from that origin", "Available host swap without an enforced container limit leaves this field `false`" ]) { if (!betaManualValidation.includes(phrase)) failures.push(`docs/BETA_CANDIDATE_MANUAL_VALIDATION.md does not preserve the clean Unraid ownership gate: ${phrase}`); diff --git a/src/server/app.ts b/src/server/app.ts index f91d389..607fcaf 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -766,7 +766,10 @@ function registerCsrfProtection(app: FastifyInstance, config: AppConfig) { if (origin && origin === expectedOrigin) return; if (!origin && fetchSite === "same-origin") return; if (!origin && !fetchSite && process.env.NODE_ENV === "test") return; - return reply.code(403).send({ error: "Cross-site request rejected." }); + return reply.code(403).send({ + error: + "This browser address does not match Moodarr's configured Web Origin. Open Moodarr using the configured scheme, host, and port, or update the deployment setting and recreate the container." + }); }); } diff --git a/tests/app.test.ts b/tests/app.test.ts index a1d6307..75d8db1 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -368,22 +368,52 @@ describe("Moodarr API", () => { expect(unlockCookies.some((value) => value.startsWith("moodarr_admin_locked="))).toBe(true); }); - it("rejects cross-site cookie-authenticated writes while accepting the configured origin", async () => { - const app = makeApp(testConfig({ requireAdminToken: true, adminAutoSession: true })); - const initial = await app.inject({ method: "GET", url: "/api/admin/session" }); - const cookie = String(Array.isArray(initial.headers["set-cookie"]) ? initial.headers["set-cookie"][0] : initial.headers["set-cookie"]).split(";")[0]; + it("rejects mismatched browser origins for cookie-authenticated search while accepting the configured origin", async () => { + const app = makeApp(testConfig({ requireAdminToken: true, adminAutoSession: false })); + const session = await app.inject({ + method: "POST", + url: "/api/admin/session", + payload: { token: "test-admin-token-secret" } + }); + expect(session.statusCode).toBe(200); + const cookie = String(Array.isArray(session.headers["set-cookie"]) ? session.headers["set-cookie"][0] : session.headers["set-cookie"]).split(";")[0]; + const payload = { query: "cozy adventure", resultLimit: 5, watchContext: "group" }; + const originError = { + error: + "This browser address does not match Moodarr's configured Web Origin. Open Moodarr using the configured scheme, host, and port, or update the deployment setting and recreate the container." + }; - const rejected = await app.inject({ + const rejectedCrossSite = await app.inject({ + method: "POST", + url: "/api/search", + headers: { cookie, origin: "https://attacker.example", "sec-fetch-site": "cross-site" }, + payload + }); + expect(rejectedCrossSite.statusCode).toBe(403); + expect(rejectedCrossSite.json()).toEqual(originError); + + const rejectedSessionDelete = await app.inject({ method: "DELETE", url: "/api/admin/session", headers: { cookie, origin: "https://attacker.example", "sec-fetch-site": "cross-site" } }); - expect(rejected.statusCode).toBe(403); + expect(rejectedSessionDelete.statusCode).toBe(403); + expect(rejectedSessionDelete.json()).toEqual(originError); + + const rejectedHostAlias = await app.inject({ + method: "POST", + url: "/api/search", + headers: { cookie, origin: "http://localhost:5173", "sec-fetch-site": "same-origin" }, + payload + }); + expect(rejectedHostAlias.statusCode).toBe(403); + expect(rejectedHostAlias.json()).toEqual(originError); const accepted = await app.inject({ - method: "DELETE", - url: "/api/admin/session", - headers: { cookie, origin: "http://127.0.0.1:5173", "sec-fetch-site": "same-origin" } + method: "POST", + url: "/api/search", + headers: { cookie, origin: "http://127.0.0.1:5173", "sec-fetch-site": "same-origin" }, + payload }); expect(accepted.statusCode).toBe(200); }); diff --git a/tests/clientApi.test.ts b/tests/clientApi.test.ts index f728027..6e1d599 100644 --- a/tests/clientApi.test.ts +++ b/tests/clientApi.test.ts @@ -56,6 +56,25 @@ describe("Moodarr client admin API", () => { await expect(moodarrApi.runSync()).rejects.toThrow("Sync is already running."); }); + it("surfaces actionable Web Origin guidance for a rejected search", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + error: + "This browser address does not match Moodarr's configured Web Origin. Open Moodarr using the configured scheme, host, and port, or update the deployment setting and recreate the container." + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ) + ) + ); + + await expect(moodarrApi.search({ query: "cozy adventure", watchContext: "group" })).rejects.toThrow( + "This browser address does not match Moodarr's configured Web Origin. Open Moodarr using the configured scheme, host, and port, or update the deployment setting and recreate the container." + ); + }); + it("scopes solo profile reads and exports to the selected Plex user", async () => { const fetchMock = mockJsonResponse({}); diff --git a/unraid/moodarr.xml b/unraid/moodarr.xml index 928c379..dbc2b9c 100644 --- a/unraid/moodarr.xml +++ b/unraid/moodarr.xml @@ -14,7 +14,7 @@ 4401 /mnt/user/appdata/moodarr - + true false