# Verification Design — Per-Goal Verification Items **Generated**: 2026-04-18 **Source**: Derived from `endpoint_scenarios.md`, `scenario_intents.md`, `scenario_effects.md` **Purpose**: For each scenario's achievement goal, design the concrete verification items (assertions + observables + tools) required to prove the goal is met. **Verification item format** (used throughout): ``` Goal: Precondition: Action: Observable: Assertion: Negative check: ``` --- # Section 1 — Queue Management Verification Design ## 1.1 POST /v2/manager/queue/task (kind=install) ### Goal A1 — Install a CNR pack to loadable state - **Precondition**: pack dir absent; `.tracking` absent - **Action**: POST queue/task kind=install params={id, version, selected_version, mode, channel} → POST queue/start - **Observable**: filesystem `custom_nodes//` + `.tracking` file content; `customnode/installed` response; WebSocket `cm-queue-status all-done` - **Assertion**: pack dir exists AND `.tracking` present AND `installed[pack_id].cnr_id == pack_id` AND version matches - **Negative check**: no leftover `.trash_*` dirs; no ModuleNotFoundError in server log ### Goal A2 — Install a nightly (URL) pack via git clone - **Precondition**: pack dir absent - **Action**: POST queue/task kind=install selected_version=nightly with `repository` URL - **Observable**: pack dir + `.git/` subdir; `.git/config` remote URL - **Assertion**: `.git` exists AND remote URL matches request - **Negative check**: no ModuleNotFoundError (git_helper.py subprocess ran cleanly) ### Goal A3 — Skip install when already disabled (re-enable shortcut) - **Precondition**: pack in `.disabled/` - **Action**: POST queue/task kind=install params.skip_post_install=true for known cnr_id - **Observable**: pack moved back to active custom_nodes/; no fresh clone - **Assertion**: pack active + no new download; existing `.tracking` preserved - **Negative check**: no re-download (verify via timing or lack of network log) ### Goal A4 — Reject bad kind (validation error) - **Precondition**: queue empty or known state - **Action**: POST queue/task with kind="garbage" - **Observable**: response status; `queue/status.total_count` before/after - **Assertion**: 400 + ValidationError text; total_count unchanged - **Negative check**: no pack changes; no task in history ### Goal A5 — Reject missing ui_id/client_id (traceability gate) - **Action**: POST queue/task without ui_id OR without client_id - **Assertion**: 400; no task queued (verify via status counts) ### Goal A6 — Worker auto-starts on task queue - **Precondition**: worker idle - **Action**: POST queue/task (any valid) - **Observable**: `queue/status.is_processing` polling - **Assertion**: within N seconds is_processing becomes true; eventually done_count increments ## 1.2 POST /v2/manager/queue/task (kind=uninstall) ### Goal U1 — Remove installed pack - **Precondition**: pack present; in `installed` list - **Action**: POST queue/task kind=uninstall params.node_name= - **Observable**: filesystem pack dir; `customnode/installed` - **Assertion**: pack dir absent AND cnr_id absent from installed - **Negative check**: other packs untouched; no orphan files in .disabled/ ### Goal U2 — Idempotent uninstall of missing pack - **Precondition**: pack absent - **Action**: POST queue/task kind=uninstall for non-existent pack - **Assertion**: task completes without raising fatal error; state unchanged ## 1.3 POST /v2/manager/queue/task (kind=update) ### Goal UP1 — Upgrade pack to newer version - **Precondition**: pack installed at version X; version Y > X available - **Action**: POST queue/task kind=update params.node_name, node_ver - **Observable**: `.tracking` file content (version field); CNR API version - **Assertion**: pack still present; new version recorded; dependencies updated - **Negative check**: no partial state (no half-cloned dir) ### Goal UP2 — Idempotent when already up-to-date - **Precondition**: pack at latest version - **Action**: POST queue/task kind=update - **Assertion**: no version downgrade; no pack removal; task completes ## 1.4 POST /v2/manager/queue/task (kind=fix) ### Goal F1 — Reinstall dependencies of existing pack - **Precondition**: pack present; dependencies broken (simulate by removing from venv) - **Action**: POST queue/task kind=fix params.node_name, node_ver - **Observable**: venv site-packages for pack's requirements; import succeeds - **Assertion**: after fix, all declared requirements importable; pack dir unchanged (same HEAD) ## 1.5 POST /v2/manager/queue/task (kind=disable) ### Goal D1 — Move pack to disabled state reversibly - **Precondition**: pack active - **Action**: POST queue/task kind=disable params.node_name, is_unknown - **Observable**: `custom_nodes/.disabled//` exists; `custom_nodes//` absent - **Assertion**: pack relocated to .disabled/; not in active installed list; on reload NODE_CLASS_MAPPINGS lacks its nodes - **Negative check**: pack files preserved (not deleted) ### Goal D2 — Idempotent disable of already-disabled pack - **Precondition**: pack in .disabled/ - **Action**: POST queue/task kind=disable - **Assertion**: pack still in .disabled/; no duplicate; no error ## 1.6 POST /v2/manager/queue/task (kind=enable) ### Goal E1 — Restore pack from .disabled/ to active - **Precondition**: pack in .disabled/ - **Action**: POST queue/task kind=enable params.cnr_id - **Observable**: `custom_nodes//` exists (may be lowercase variant); .disabled/ entry gone - **Assertion**: pack active; appears in installed; on next reload nodes load - **Negative check**: no duplicate entries ## 1.7 POST /v2/manager/queue/install_model ### Goal IM1 — Download model to correct subdirectory - **Precondition**: model file absent; URL in whitelist - **Action**: POST queue/install_model with valid ModelMetadata + client_id + ui_id - **Observable**: `models//` file; file size > 0 - **Assertion**: file exists + non-zero size; externalmodel/getlist shows installed=True - **Negative check**: no orphan partial downloads ### Goal IM2 — Reject missing client_id / ui_id - **Action**: POST install_model without one of them - **Assertion**: 400; no task queued; no download attempted ### Goal IM3 — Reject non-whitelist URL (legacy) - **Action**: POST install_model with URL NOT in whitelist - **Assertion**: 400; no file written ### Goal IM4 — Block non-safetensors below high+ security - **Precondition**: security_level < high+ - **Action**: POST install_model with filename="*.ckpt" - **Assertion**: 403; no file written ## 1.8 POST /v2/manager/queue/update_all ### Goal UA1 — Queue update tasks for all active packs - **Precondition**: N active packs installed; queue empty - **Action**: POST queue/update_all with ui_id, client_id, mode - **Observable**: `queue/status.pending_count` delta - **Assertion**: pending_count increased by N (minus manager-skip if desktop); each queued task has kind=update + correct node_name - **Negative check**: comfyui-manager NOT in queue if __COMFYUI_DESKTOP_VERSION__ set ### Goal UA2 — Security gate ( - **Assertion**: response JSON matches file contents ### Goal QH2 — Reject path traversal - **Action**: GET queue/history?id=../../../etc/passwd - **Assertion**: 400 "Invalid history id"; no file read attempt ### Goal QH3 — Filter by ui_id / client_id / pagination - **Action**: GET queue/history with respective query params - **Assertion**: results properly filtered/paginated ## 1.14 GET /v2/manager/queue/history_list ### Goal QHL1 — List batch IDs sorted by mtime - **Precondition**: files exist in history dir - **Action**: GET queue/history_list - **Assertion**: ids list == filenames (stem) sorted by mtime desc ### Goal QHL2 — Empty list when no history - **Precondition**: empty dir - **Assertion**: `{ids: []}`; 200 --- # Section 2 — CustomNode Info Verification Design ## 2.1 GET /v2/customnode/getmappings ### Goal CM1 — Return comprehensive node→pack mapping - **Action**: GET getmappings?mode=local (or cache/remote) - **Observable**: response dict; current NODE_CLASS_MAPPINGS - **Assertion**: every currently-loaded node appears in some entry's node_list OR matches a nodename_pattern regex - **Negative check**: no stale entries for uninstalled packs ### Goal CM2 — Nickname mode applies filter - **Action**: GET getmappings?mode=nickname - **Assertion**: entries include nickname field filtered by nickname_filter rules ### Goal CM3 — Require explicit mode - **Action**: GET getmappings (no mode param) - **Assertion**: server error (500/KeyError); no partial data ## 2.2 GET /v2/customnode/fetch_updates (deprecated) ### Goal FU1 — Signal deprecation to clients - **Action**: GET fetch_updates?mode=local - **Assertion**: 410 status; body `{deprecated: true, error: ..., message: ...}` - **Negative check**: no git fetch executed (no mtime changes on .git/) ## 2.3 GET /v2/customnode/installed ### Goal IL1 — Current installed packs - **Precondition**: known packs installed - **Action**: GET installed - **Assertion**: response dict keys include all installed pack identifiers; each entry has cnr_id + version + enabled ### Goal IL2 — imported mode is startup-frozen - **Precondition**: install a new pack post-startup - **Action**: GET installed?mode=imported - **Assertion**: new pack absent from response (startup snapshot unchanged) - **Negative check**: default mode DOES show new pack (proves they differ) ## 2.4 POST /v2/customnode/import_fail_info ### Goal IF1 — Return failure info for failed pack - **Precondition**: pack exists in `cm_global.error_dict` - **Action**: POST import_fail_info {cnr_id} - **Assertion**: 200; response has `msg` + `traceback`; content matches error_dict entry ### Goal IF2 — Reject unknown pack - **Action**: POST import_fail_info {cnr_id: "unknown-12345"} - **Assertion**: 400; no response body with info ### Goal IF3 — Validate request body shape - **Action**: POST import_fail_info with non-dict OR missing both fields OR wrong type - **Assertion**: 400 with specific error text ## 2.5 POST /v2/customnode/import_fail_info_bulk ### Goal IFB1 — Return per-pack lookup results - **Action**: POST import_fail_info_bulk {cnr_ids: [known, unknown]} - **Assertion**: 200; response maps known→info dict, unknown→null ### Goal IFB2 — Reject empty lists - **Action**: POST with {cnr_ids:[], urls:[]} - **Assertion**: 400 "Either 'cnr_ids' or 'urls' field is required" --- # Section 3 — Snapshot Verification Design ## 3.1 GET /v2/snapshot/get_current ### Goal SG1 — Capture current state accurately - **Precondition**: known set of packs installed - **Action**: GET snapshot/get_current - **Assertion**: response has comfyui (hash/tag), git_custom_nodes (list), cnr_custom_nodes (list), pips; entries match actual installed state ## 3.2 POST /v2/snapshot/save ### Goal SS1 — Persist current state to retrievable file - **Precondition**: snapshot dir exists - **Action**: POST snapshot/save - **Observable**: new file in manager_snapshot_path; snapshot/getlist response - **Assertion**: new timestamped file created; content matches get_current() at save time; appears in getlist.items - **Negative check**: other snapshots untouched ### Goal SS2 — Multiple saves create distinct files - **Action**: POST save; wait 1s; POST save - **Assertion**: 2 distinct new entries in getlist ## 3.3 GET /v2/snapshot/getlist ### Goal SL1 — List matches filesystem - **Action**: GET snapshot/getlist - **Assertion**: items == .json files in snapshot dir (stems), desc sorted ## 3.4 POST /v2/snapshot/remove ### Goal SR1 — Delete snapshot permanently - **Precondition**: target snapshot exists - **Action**: POST snapshot/remove?target= - **Assertion**: file removed from disk; absent from getlist - **Negative check**: other snapshots untouched ### Goal SR2 — Idempotent on missing target - **Action**: POST remove?target=nonexistent - **Assertion**: 200 no-op ### Goal SR3 — Reject path traversal - **Action**: POST remove?target=../../etc/passwd - **Assertion**: 400 "Invalid target"; no file ops outside snapshot dir ### Goal SR4 — Security gate ( - **Observable**: manager_startup_script_path for `restore-snapshot.json` - **Assertion**: restore-snapshot.json exists; content == target snapshot; (downstream: reboot → state reverts) ### Goal SR6 — Security gate (/ + `.tracking`; UI: row badge changes to "Installed"; WebSocket: cm-queue-status all-done - **Assertion**: backend install effect + UI state updates ### Goal LB2 — Uninstall via UI - **Precondition**: pack installed - **Action (UI)**: click "Uninstall" button on row - **Observable**: backend: pack dir removed; UI: row shows "Not Installed" - **Assertion**: both states consistent ### Goal LB3 — Disable via UI - **Action (UI)**: click "Disable" button on row - **Assertion**: pack in .disabled/; UI row shows "Disabled" ### Goal LB4 — Update all via menu - **Action (UI)**: Manager menu → "Update All" - **Observable**: queue/status; progress indicator in UI - **Assertion**: N tasks queued; UI progress reflects N; eventual completion ### Goal LB5 — Batch partial failure reporting - **Action (UI)**: batch with one guaranteed-fail pack - **Observable**: response.failed array; UI notification - **Assertion**: failed list contains only failed pack id; others succeeded ## 6.2 GET /v2/customnode/getlist ### Goal LG1 — Populate Custom Nodes Manager dialog - **Action (UI)**: click "Custom Nodes Manager" from Manager menu - **Observable**: UI grid rows - **Assertion**: rows > 0; each row has Title + Installed/Not-Installed state + Install/Uninstall button ### Goal LG2 — skip_update=true optimizes loading - **Action (UI)**: open dialog with flag set (URL param or setting) - **Observable**: loading time; network log - **Assertion**: no git fetch calls; load time < N seconds ## 6.3 GET /customnode/alternatives ### Goal LA1 — Show alternatives on dedicated UI flow - **Action (UI)**: trigger alternatives display (specific button or context) - **Observable**: alternatives panel/dialog - **Assertion**: populated with alter-list.json entries (at least one if data exists) ## 6.4 GET /v2/externalmodel/getlist ### Goal LM1 — Populate Model Manager dialog - **Action (UI)**: open Model Manager - **Observable**: UI grid - **Assertion**: rows > 0; each row has `installed` flag accurately reflecting filesystem ### Goal LM2 — Install flag correctness - **Precondition**: pre-existing model file at known path - **Action (UI)**: open Model Manager - **Assertion**: that model row shows "Installed" ## 6.5 GET /v2/customnode/versions/{node_name} ### Goal LV1 — Version dropdown on Install click - **Action (UI)**: click Install on a CNR pack row - **Observable**: version selector (select multiple) in modal - **Assertion**: dropdown options match CNR registry versions for that pack; "latest" option present ### Goal LV2 — Unknown pack → 400 (UI should handle) - **Action**: direct API call with bogus node_name - **Assertion**: 400; UI shows error or skips ## 6.6 GET /v2/customnode/disabled_versions/{node_name} ### Goal LDV1 — Show disabled versions for re-enable - **Precondition**: pack has disabled versions - **Action (UI)**: open pack's "Disabled Versions" dropdown - **Observable**: dropdown options - **Assertion**: options match backend disabled_versions response ## 6.7 POST /v2/customnode/install/git_url ### Goal LGU1 — Install via "Install via Git URL" button - **Precondition**: security_level ≥ high+; pack absent - **Action (UI)**: click button → enter URL → confirm - **Observable**: custom_nodes// + .git/ - **Assertion**: pack cloned; UI reflects ### Goal LGU2 — Security gate - **Precondition**: security_level < high+ - **Assertion**: 403 from API; UI shows error; no clone occurs ## 6.8 POST /v2/customnode/install/pip ### Goal LPP1 — Install pip packages via UI - **Action (UI)**: trigger pip install flow with known package - **Observable**: venv site-packages - **Assertion**: package importable after install ### Goal LPP2 — Security gate - **Assertion**: 403 at lower security; no pip invocation ## 6.9 GET /v2/manager/notice ### Goal LN1 — Fetch and display News - **Precondition**: GitHub reachable - **Action**: GET manager/notice (triggered on Manager menu open) - **Assertion**: HTML response; contains expected markdown-body content; footer has ComfyUI + Manager version ### Goal LN2 — Graceful on GitHub unreachable - **Precondition**: network blocked - **Assertion**: "Unable to retrieve Notice" returned; no server crash; UI shows message ### Goal LN3 — Non-git ComfyUI warning - **Precondition**: ComfyUI dir not a git repo - **Assertion**: response starts with "isn't git repo" warning paragraph ### Goal LN4 — Outdated ComfyUI warning - **Precondition**: comfy_ui_commit_datetime.date() < required_commit_datetime.date() - **Assertion**: response starts with "too OUTDATED!!!" paragraph --- # Section 7 — Cross-cutting Intent Pattern Templates Reusable verification templates organized by intent category. Apply the template to any scenario of that type. ## 7.1 User Capability Template **Goal**: User accomplishes task X. **Verification structure**: 1. **Precondition check**: state is F0 (function not yet performed) 2. **Invoke action**: endpoint call or UI interaction 3. **Wait for completion**: poll status endpoint OR WebSocket event OR filesystem observer 4. **Observable assertions** (in order): - Response status 2xx - Response body schema correct - Side-effect state is F1 (function performed) - Secondary state changes (counters, history, lists) reflect the change 5. **Negative checks**: - No orphan state (partial writes, stale locks) - Other unrelated state unchanged (blast radius contained) 6. **Cleanup**: restore F0 where possible (for idempotent tests) **Examples in this report**: A1, A2, U1, UP1, D1, E1, IM1, UA1, R1, S1, SS1, SR1, C2, C5, V3, CV3, LB1-LB5, LGU1, LPP1 ## 7.2 Input Resilience Template **Goal**: Bad input must be rejected without side effects. **Verification structure**: 1. **Precondition**: system in known-good state S0 2. **Action**: send malformed/invalid input (craft variants per endpoint): - Malformed JSON (raw text, wrong Content-Type) - Missing required field - Wrong type for field - Out-of-range value - Extraneous unexpected field (should be ignored) 3. **Observable assertions**: - Response 4xx (400 typical; 500 only if server constraint like KeyError) - Error message identifies the problem (if surfaced) 4. **Negative checks** (the critical part): - State S1 == S0 (no mutation) - Queue/history not populated - Filesystem unchanged - No downstream side effects (no email, no download, no process start) **Examples**: A4, A5, U1 (missing target), IM2, UA3, UC2 edges, QH2, IF2, IF3, IFB2, C3, LV2 ## 7.3 Security Boundary Template **Goal**: Operations requiring privilege X must fail at lower privilege. **Verification structure**: 1. **Precondition**: server running at specific `security_level` below the gate 2. **Action**: invoke the privileged endpoint 3. **Observable assertions**: - Response 403 - Server log contains security-denied message 4. **Critical negative checks**: - NO task queued - NO filesystem change - NO network operation initiated - NO config change - NO process change (no restart, no exit) 5. **Positive counterpart**: at or above required level, operation succeeds **Security tiers to cover**: - `middle` — reboot, snapshot/remove, _uninstall, _update - `middle+` — update_all, snapshot/restore, _install_custom_node, _install_model - `high+` — comfyui_switch_version, install/git_url, install/pip, non-safetensors model, _fix (`middle` → `high` in commit `c8992e5d`; subsequent `high` → `high+` in WI-#235 to align the gate with the `SECURITY_MESSAGE_HIGH_P` log text) **Path-traversal sub-template** (within security boundary): 1. Action: request with `../`, absolute path, encoded traversal 2. Assertion: 400 "Invalid target" or similar 3. Negative check: target file unchanged; no files outside the endpoint's scope accessed **Examples**: IM4, UA2, SR3, SR4, SR6, V5, CV4, LGU2, LPP2, QH2 ## 7.4 Observability Template **Goal**: Caller can trust the response to reflect actual system state. **Verification structure**: 1. **Establish known state**: inject a known delta (queue a task, install a pack, save a snapshot) 2. **Read endpoint**: GET the observability endpoint 3. **Assertion**: response reflects the known delta 4. **Consistency check**: read again immediately; result identical (no race/jitter) 5. **Schema check**: all expected fields present with correct types **Required-identity fields** (traceability sub-pattern): - client_id + ui_id must be present in inputs for all state-mutating endpoints - Verify that history/status queries can locate tasks by these IDs **Examples**: QS1, QS2, QH1, QH3, IL1, IL2, SL1, C1, V1, V2, CV1, LG1, LM1, LM2, LV1, LDV1 ## 7.5 Idempotency Template **Goal**: Re-invoking an operation on an already-satisfied state is safe. **Verification structure**: 1. **Invoke operation** to reach state F1 2. **Invoke same operation again** (no intermediate state change) 3. **Assertions**: - Response 2xx (same or semantically equivalent status, e.g., 200/201) - State remains F1 (no regression, no duplicate) - No error raised 4. **Stress variant**: invoke N times in rapid succession; ensure no race condition **Examples**: U2, UP2, D2, E2, R2, S2, SS2 (distinct but both succeed), SR2, C6 ## 7.6 Data Integrity Template **Goal**: Config / runtime constants remain stable and consistent. **Verification structure**: 1. **Read constant twice** (consecutive or across operations) 2. **Assertion**: identical values 3. **Persistence across restart**: set value → reboot → re-read 4. **Assertion**: value persisted **Examples**: V1 (version idempotent), C2 (db_mode survives restart), CV1 (versions list stable) ## 7.7 Recovery Template **Goal**: System can recover from failure or drifted state. **Verification structure**: 1. **Induce failure state**: delete dependency, corrupt a file, disable a pack 2. **Invoke recovery operation**: fix, restore, re-enable 3. **Assertion**: state is healthy again; pack/module loads 4. **Negative check**: no data loss; original files preserved where applicable **Examples**: F1 (fix dependency), E1 (enable disabled), SR5 (restore snapshot) ## 7.8 Concurrency Safety Template **Goal**: Parallel or duplicated operations don't corrupt state. **Verification structure**: 1. **Setup parallel trigger**: fire multiple start/reset/task commands in rapid succession 2. **Assertions**: - No duplicate worker thread (single pid in logs) - No task processed twice (idempotent task-id check) - No race-condition data corruption (e.g., half-written config) 3. **Stress test variant**: N parallel requests, assert linearized outcome **Examples**: S2 (duplicate start), R1 (reset during processing) --- # Section 8 — Verification Matrix Summary | Intent category | Template | Scenarios using | Test type needed | |---|---|---:|---| | User capability | 7.1 | 62 | E2E effect verification (real install/remove/save etc.) | | Input resilience | 7.2 | 32 | Negative tests with negative-check assertions | | Security boundary | 7.3 | 15 | Permission tests at each security level | | Observability | 7.4 | 16 | GET correctness + traceability tests | | Idempotency | 7.5 | 14 | Repeat-call tests | | Data integrity | 7.6 | 8 | Cross-restart persistence tests | | Recovery | 7.7 | 5 | Fault-injection tests | | Concurrency safety | 7.8 | 2 | Parallel-call stress tests | --- # Section 9 — Practical Implementation Priority Based on security impact + existing coverage gaps: ## 🔴 Must-add (security + integrity) 1. **Path traversal tests** for snapshot/remove, snapshot/restore, queue/history (Section 7.3 path sub-template) 2. **Security gate 403 tests** for each tier — requires running with restricted security_level in separate test run 3. **Config persistence across restart** — set db_mode → reboot → verify (Section 7.6) ## 🟡 Should-add (coverage quality) 4. **UI-driven install/uninstall flow** (LB1-LB3) — convert debug-install-flow.spec.ts to assertion test 5. **install_model effect** — current test only checks 200; add queue/status verification (Goal IM1) 6. **Fix recovery test** — induce broken dependency, verify fix heals (Goal F1) ## 🟢 Nice-to-have 7. Concurrency tests (duplicate queue/start; parallel task queueing) 8. get_current snapshot content fidelity — compare to actual installed state 9. Update version bump verification — test install v1.0.0 → update → expect v1.0.1 marker --- # Section 10 — Security Mitigation Contracts Layer: CSRF method-rejection (GET→POST conversion, commit `99caef55`). This section formalizes the contract exercised by TWO test files — one per server variant (mutex loading via `--enable-manager-legacy-ui`): - `tests/e2e/test_e2e_csrf.py` — glob server (4 functions / 26 parametrized invocations — post-WI-HH; was 29 before the 3 dual-purpose endpoints were removed from the reject-GET fixture) - `tests/e2e/test_e2e_csrf_legacy.py` — legacy server (4 functions / 26 parametrized invocations; WI-FF added, WI-GG audit-integrated) Scope is deliberately narrow — see each test file's SCOPE docstring: only the method-reject layer, not Origin/Referer/same-site/anti-token defenses (those are handled by `origin_only_middleware` at the aiohttp layer and are out of scope here). The full 16-endpoint inventory is recorded in `reports/endpoint_scenarios.md` under "CSRF Method-Reject Contract Inventory"; the legacy file substitutes `/v2/manager/queue/batch` for `/v2/manager/queue/task` (glob-only) and scopes the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) to the ALLOW-GET class only. ## 10.1 CSRF Method-Reject Contract ### Goal CSRF-M1 — State-changing endpoints reject HTTP GET (13 endpoints, post-WI-HH) - **Precondition**: ComfyUI running; the 13 state-changing endpoints from `STATE_CHANGING_POST_ENDPOINTS` declared as `@routes.post(...)` in `comfyui_manager/glob/manager_server.py` (mirror in `comfyui_manager/legacy/manager_server.py`) — post-WI-HH count; excludes the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) whose GET path legitimately reads the current value - **Action**: HTTP GET to each endpoint path (no body, `allow_redirects=False`, module-scoped ComfyUI fixture) - **Observable**: HTTP status code; response body (first 200 chars on failure) - **Assertion**: `status_code not in range(200, 400)` AND `status_code in (400, 403, 404, 405)` for every path in the 13-endpoint fixture (post-WI-HH; the legacy counterpart also iterates 13 paths after substituting `queue/batch` for `queue/task`) - **Negative check**: no 2xx success and no 3xx redirect on any state-changing path (blocks ``, link-click, and redirect-based cross-origin triggers — CVSS 8.1 vector from XlabAI/Tencent Xuanwu report) - **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestStateChangingEndpointsRejectGet::test_get_is_rejected` (1 function × 13 parametrized invocations — post-WI-HH) - **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected` (1 function × 13 parametrized invocations — `queue/batch` replaces `queue/task`; the 3 dual-purpose endpoints are covered via the read-path under CSRF-M3) ### Goal CSRF-M2 — POST counterparts still succeed (positive control) - **Precondition**: ComfyUI running; clean snapshot state - **Action**: POST `/v2/manager/queue/reset` (no-op reset), then POST `/v2/snapshot/save` (creates a snapshot, cleaned up via `/v2/snapshot/remove`) - **Observable**: HTTP 200 response on both POSTs; on save, the new entry is observable via `/v2/snapshot/getlist` - **Assertion**: `status_code == 200` for both POSTs; cleanup removes the just-created snapshot - **Negative check**: the CSRF fix must NOT break the legitimate POST path (regression guard — functional equivalence of the converted endpoints must hold) - **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestCsrfPostWorks` (2 functions: `test_queue_reset_post_works`, `test_snapshot_save_post_works`) - **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyCsrfPostWorks` (2 functions — same endpoints, legacy server fixture) ### Goal CSRF-M3 — Read-only endpoints still allow GET (negative control, 11 endpoints) - **Precondition**: ComfyUI running - **Action**: HTTP GET to 11 read-only endpoints: `/v2/manager/version`, `/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list`, `/v2/manager/queue/status`, `/v2/manager/queue/history_list`, `/v2/manager/is_legacy_manager_ui`, `/v2/customnode/installed`, `/v2/snapshot/getlist`, `/v2/snapshot/get_current`, `/v2/comfyui_manager/comfyui_versions` - **Observable**: HTTP status code - **Assertion**: `status_code == 200` for every read-only path - **Negative check**: the CSRF fix must NOT over-correct by making pure-read endpoints POST-only (would break UI flows that ``-safe-read via GET). Note: the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) appear in BOTH CSRF-M1 (write path requires POST; plain GET is rejected) AND CSRF-M3 (read path still answers GET 200); this dual appearance is intentional — they were split into GET (read) + POST (write) by 99caef55. - **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds` (1 function × 11 parametrized invocations) - **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds` (1 function × 11 parametrized invocations — same endpoint list) ## 10.2 Out-of-Scope CSRF Layers (tracked for future verification) - **Origin/Referer validation** — `origin_only_middleware` (aiohttp layer); not exercised by `test_e2e_csrf.py` - **Same-site cookie enforcement** — browser-layer concern; not server-testable in isolation - **Anti-CSRF token verification** — not implemented in current codebase - **Cross-site form POST defense** — subsumed by Origin validation above These remain Goals for future work; do not infer coverage from Goals CSRF-M1/M2/M3. --- *End of Verification Design Report*